Stake Validator Pattern
Delegate computation to a staking script using the “withdraw zero trick” to reduce execution costs from O(N²) to O(N).
The Challenge
When a Cardano validator processes multiple UTxOs in a single transaction, the spending script runs once per input. If each execution performs expensive operations (iterating all inputs, checking all outputs), costs grow quadratically:
- 10 inputs = 10 script executions Ă— 10 iterations = 100 operations
- 20 inputs = 20 script executions Ă— 20 iterations = 400 operations
This makes batch operations prohibitively expensive.
How It Works
- Spending validator (runs per UTxO): Minimal logic - just checks stake validator ran
- Stake validator (runs once): Heavy computation in the reward endpoint
The spending validator essentially says: “As long as there is a reward withdrawal of the given script in this transaction, this UTxO can be spent.”
When to Use This Pattern
Best for:
- Consolidating complex business logic into a single validation point
- Batch operations processing multiple UTxOs together
- Separating payment validation from protocol logic (better composability)
- Reducing script size and execution costs
Not ideal for:
- Single input transactions (no benefit)
- Cases where each input needs unique, complex validation
- Protocols where staking credentials aren’t suitable
API Reference
| Function | Description |
|---|---|
spend | Check stake validator ran + validate its redeemer and withdrawal amount |
spendMinimal | Just check stake validator ran (most common) |
withdraw | Helper for reward endpoint - extracts script hash from credential |
Implementation Guide
Spending Endpoint
import scalus.patterns.StakeValidator
@Compile
object MyValidator extends Validator {
inline override def spend(
datum: Option[Data],
redeemer: Redeemer,
tx: TxInfo,
ownRef: TxOutRef
): Unit = {
val ownCredential = tx.findInputOrFail(ownRef).resolved.address.credential
val ownScriptHash = ownCredential.scriptHashOrFail("Own address must be Script")
// Option 1: Just check stake validator ran (withdraw zero trick)
StakeValidator.spendMinimal(ownScriptHash, tx)
// Option 2: Also validate redeemer and withdrawal amount
StakeValidator.spend(
withdrawalScriptHash = ownScriptHash,
withdrawalRedeemerValidator =
(redeemer, lovelace) => require(lovelace === BigInt(0), "Withdrawal must be zero"),
txInfo = tx
)
}
}tx.findInputOrFail(ownRef) scans the inputs once and fails with a message when the own input is
missing; credential.scriptHashOrFail(msg) projects the script hash out of the credential or fails.
Neither builds an intermediate Option.
Reward Endpoint (Stake Validator)
inline override def reward(
redeemer: Redeemer,
stakingKey: Credential,
tx: TxInfo
): Unit = {
StakeValidator.withdraw(
withdrawalValidator = (redeemer, validatorHash, txInfo) => {
// Your heavy validation logic here
// This runs ONCE for all inputs
val totalInputValue = txInfo.inputs.foldLeft(BigInt(0)) { (acc, input) =>
acc + input.resolved.value.getLovelace
}
// Verify outputs match expected distribution
require(totalInputValue === expectedTotal, "Input total mismatch")
},
redeemer = redeemer,
credential = stakingKey,
txInfo = tx
)
}Callbacks return Unit
Every validator callback in StakeValidator, TransactionLevelMinterValidator and
UtxoIndexer has the type ... => Unit. The callback performs its own require(cond, message)
or fail(message) calls; the library does not inspect a return value. A Boolean callback could
only ever fail with one generic message, and a false from a missed branch was silent.
Migration hazard. A lambda that returns Boolean still compiles against a Unit parameter
(Scala discards the value) and then checks nothing:
// Compiles. Checks nothing. The withdrawal amount is never verified.
withdrawalRedeemerValidator = (redeemer, lovelace) => lovelace === BigInt(0)
// Correct: the condition is inside require
withdrawalRedeemerValidator =
(redeemer, lovelace) => require(lovelace === BigInt(0), "Withdrawal must be zero")After upgrading, grep every callback passed to these patterns and wrap each condition in
require(..., message).
Script Configuration: The spending script address must have a staking credential that points to your withdrawal validator. This is configured when deploying scripts.
Double Satisfaction: When using this pattern with multiple inputs, ensure proper input-to-output mapping to prevent double satisfaction vulnerabilities. See Common Vulnerabilities.
Example: Optimized Payment Splitter
The scalus-examples module includes a side-by-side comparison:
| Validator | Description |
|---|---|
NaivePaymentSplitterValidator | Runs full validation per UTxO (O(N²)) |
OptimizedPaymentSplitterValidator | Uses stake validator pattern (O(N)) |
Real-world savings: Tests show ~71% reduction in memory and CPU costs when spending multiple UTxOs with the optimized version.
The optimized version also uses SpendRedeemer.ownInputIndex to avoid iterating through all inputs to find the own input:
case class SpendRedeemer(ownInputIndex: BigInt) derives ToData, FromData
// Spending endpoint - O(1) input lookup
inline override def spend(...): Unit = {
val ownInput = tx.inputs.at(spendRedeemer.ownInputIndex)
require(ownInput.outRef === ownRef, "Own input index mismatch")
val ownScriptHash =
ownInput.resolved.address.credential.scriptHashOrFail("Own address must be Script")
// Just check stake validator ran (withdraw zero trick)
StakeValidator.spendMinimal(ownScriptHash, tx)
}The stake validator’s reward endpoint receives pre-computed values and verifies them:
case class SplitVerificationRedeemer(
payeeWithChange: PubKeyHash,
sumContractInputs: BigInt,
splitPerPayee: BigInt,
nPayed: BigInt
) derives ToData, FromData
// Reward endpoint - runs ONCE, verifies claimed values match transaction
inline override def reward(...): Unit = {
val verification = redeemer.to[SplitVerificationRedeemer]
// Verify sumContractInputs matches actual inputs
// Verify outputs match claimed split amounts
// All heavy iteration happens here, once
}Related Patterns
- Merkelized Validator - When spending validators need to read verified data from stake validator
- Transaction Level Minter - Similar pattern using minting instead of staking
Resources
- Anastasia Labs: Stake Validator Pattern - Original pattern documentation
- Scalus Design Patterns - Implementation and tests
- OptimizedPaymentSplitterValidator - Optimized example with 71% savings
- NaivePaymentSplitterValidator - Naive version for comparison
- PaymentSplitterTxBuilderTest - Cost comparison tests