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 ownScriptHash = tx.findOwnInputOrFail(ownRef).resolved.address.credential .scriptOption.getOrFail("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) => lovelace === BigInt(0), txInfo = tx ) } }

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 true }, redeemer = redeemer, credential = stakingKey, txInfo = tx ) }

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 .scriptOption.getOrFail("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