Transaction Level Minter Pattern
Couple spending and minting endpoints of the same validator to delegate heavy computation to a single minting execution.
The Challenge
Like the Stake Validator Pattern, spending validators that run per-UTxO create quadratic costs for batch operations. However, not all protocols use staking, and some naturally involve minting or burning tokens as part of their logic.
How It Works
- Spending validator (runs per UTxO): Minimal - just checks minting endpoint executes
- Minting validator (runs once): Heavy computation when minting/burning tokens
The spending validator only ensures the minting endpoint executes by verifying a non-zero amount of its asset is being minted or burnt.
When to Use This Pattern
Best for:
- Protocols that already use beacon tokens or minting/burning
- State machines where minting signals state transitions
- Cases where token existence proves validation occurred
Not ideal for:
- Protocols without natural minting requirements (use Stake Validator instead - lower execution costs)
- Cases where spend validation must be independent of minting
- When minting logic should not control spending authorization
Prefer Stake Validator when possible. If minting tokens isn’t a natural part of your protocol, use the Stake Validator pattern instead—it has lower execution unit costs.
API Reference
| Function | Description |
|---|---|
spend | Check minting policy ran + validate its redeemer and minted tokens |
spendMinimal | Just check at least one token is minted/burnt with the policy |
Implementation Guide
Spending Endpoint
import scalus.patterns.TransactionLevelMinterValidator
@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 minting policy ran
TransactionLevelMinterValidator.spendMinimal(ownScriptHash, tx)
// Option 2: Also validate redeemer and minted tokens
TransactionLevelMinterValidator.spend(
minterScriptHash = ownScriptHash,
minterRedeemerValidator = redeemer =>
require(redeemer.to[MintRedeemer].maxUtxosToSpend > 0, "maxUtxosToSpend must be positive"),
minterTokensValidator = tokens => {
val (tokenName, qty) = tokens.singleOrFail("Exactly one BEACON entry")
require(tokenName === utf8"BEACON", "Unexpected token name")
require(qty === BigInt(1) || qty === BigInt(-1), "BEACON quantity must be 1 or -1")
},
txInfo = tx
)
}
}Both callbacks return Unit: each check is a require with its own message, so a failure names
the check that tripped. tokens.singleOrFail(msg) returns the only entry of the map and fails when
there are zero or several, which is what tokens.toList.head could not express. See the
Stake Validator page for the
migration hazard that applies to every callback in this module.
Minting Endpoint
inline override def mint(
redeemer: Redeemer,
policyId: PolicyId,
tx: TxInfo
): Unit = {
val mintRedeemer = redeemer.to[MintRedeemer]
// Your heavy validation logic here - runs ONCE
val ownCredential = Credential.ScriptCredential(policyId)
val scriptInputsCount = tx.inputs.count(_.resolved.address.credential === ownCredential)
require(scriptInputsCount === mintRedeemer.expectedInputCount, "Input count mismatch")
// Verify all outputs meet requirements
// ... additional validation logic
}Example: Beacon Token Protocol
A common use case is minting/burning a “beacon” token to signal state changes:
case class SpendRedeemer(ownIndex: BigInt, burn: Boolean) derives FromData
case class MintRedeemer(maxUtxosToSpend: BigInt) derives FromData
// Spending endpoint - checks beacon is minted/burnt
inline override def spend(datum: Option[Data], redeemer: Redeemer, tx: TxInfo, ownRef: TxOutRef): Unit = {
val spendRedeemer = redeemer.to[SpendRedeemer]
val input = tx.inputs.get(spendRedeemer.ownIndex).getOrFail("Undefined ownIndex")
require(ownRef === input.outRef, "ownIndex does not point at the spending UTxO")
val ownHash = input.resolved.address.credential.scriptHashOrFail("Own address must be Script")
TransactionLevelMinterValidator.spend(
minterScriptHash = ownHash,
minterRedeemerValidator = redeemer =>
require(redeemer.to[MintRedeemer].maxUtxosToSpend > 0, "maxUtxosToSpend must be positive"),
minterTokensValidator = tokens => {
val (tokenName, qty) = tokens.singleOrFail("Exactly one BEACON entry")
require(tokenName === utf8"BEACON")
require(
if spendRedeemer.burn then qty === BigInt(-1) else qty === BigInt(1),
"BEACON quantity must be -1 on burn and 1 on mint"
)
},
txInfo = tx
)
}
// Minting endpoint - heavy logic runs once
inline override def mint(redeemer: Redeemer, policyId: PolicyId, tx: TxInfo): Unit = {
val mintRedeemer = redeemer.to[MintRedeemer]
val ownCredential = Credential.ScriptCredential(policyId)
val scriptInputsCount = tx.inputs.count(_.resolved.address.credential === ownCredential)
require(scriptInputsCount === mintRedeemer.maxUtxosToSpend)
}Stake Validator vs Transaction Level Minter: Both patterns delegate computation to a single execution. Choose based on your protocol:
- Stake Validator: When you don’t need minting/burning, or staking is already part of your design
- Transaction Level Minter: When beacon tokens or minting/burning naturally fits your protocol
Related Patterns
- Stake Validator - Similar pattern using staking instead of minting
- Merkelized Validator - When spending validators need to read verified data
Resources
- Anastasia Labs: Transaction Level Validator - Original pattern documentation
- Scalus Design Patterns - Implementation and tests
- TransactionLevelMinterValidatorExample - Complete example