Skip to Content
Scalus Club is now open! Join us to get an early access to new features 🎉

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

  1. Spending validator (runs per UTxO): Minimal logic - just checks stake validator ran
  2. 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

FunctionDescription
spendCheck stake validator ran + validate its redeemer and withdrawal amount
spendMinimalJust check stake validator ran (most common)
withdrawHelper 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:

ValidatorDescription
NaivePaymentSplitterValidatorRuns full validation per UTxO (O(N²))
OptimizedPaymentSplitterValidatorUses 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 }

Resources

Last updated on