# Scalus example contracts Working validators with tests, from the Scalus repository. Study these before writing new Scalus code. HTLC is the reference style. # Example: htlc ## scalus-examples/jvm/src/main/scala/scalus/examples/htlc/HtlcContract.scala ```scala package scalus.examples.htlc import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 object HtlcContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(HtlcValidator.validate) lazy val blueprint = Blueprint.plutusV3[Config, Action]( title = "Hashed timelocked contract", description = "Releases funds when recipient reveals hash preimage before deadline, otherwise refunds to sender.", version = "1.0.0", license = Some("Apache License Version 2.0"), compiled = compiled ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/htlc/HtlcTransactions.scala ```scala package scalus.examples.htlc import scalus.uplc.builtin.Data import scalus.cardano.address.Address import scalus.cardano.ledger.* import scalus.cardano.txbuilder.* import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.uplc.PlutusV3 import java.time.Instant case class HtlcTransactions( env: CardanoInfo, contract: PlutusV3[Data => Unit] ) { private val scriptAddress: Address = contract.address(env.network) private val builder = TxBuilder(env) def lock( utxos: Utxos, value: Value, sponsor: Address, committer: AddrKeyHash, receiver: AddrKeyHash, image: Image, timeout: Instant, signer: TransactionSigner ): Transaction = { val datum = Config(PubKeyHash(committer), PubKeyHash(receiver), image, timeout.toEpochMilli) builder .payTo(scriptAddress, value, datum) .complete(availableUtxos = utxos, sponsor = sponsor) .sign(signer) .transaction } def reveal( utxos: Utxos, lockedUtxo: Utxo, payeeAddress: Address, sponsor: Address, preimage: Preimage, receiverPkh: AddrKeyHash, validTo: Instant, signer: TransactionSigner ): Transaction = { val redeemer = Action.Reveal(preimage) builder .spend(lockedUtxo, redeemer, contract) .requireSignature(receiverPkh) .payTo(payeeAddress, lockedUtxo.output.value) .validTo(validTo) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } def timeout( utxos: Utxos, lockedUtxo: Utxo, payeeAddress: Address, sponsor: Address, committerPkh: AddrKeyHash, validFrom: Instant, signer: TransactionSigner ): Transaction = { val redeemer = Action.Timeout builder .spend(lockedUtxo, redeemer, contract) .requireSignature(committerPkh) .payTo(payeeAddress, lockedUtxo.output.value) .validFrom(validFrom) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/htlc/HtlcValidator.scala ```scala package scalus.examples.htlc import scalus.compiler.Compile import scalus.uplc.builtin.Builtins.sha3_256 import scalus.uplc.builtin.Data.{FromData, ToData} import scalus.uplc.builtin.{ByteString, Data, FromData, ToData} import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* type Preimage = ByteString type Image = ByteString // Datum case class Config( committer: PubKeyHash, receiver: PubKeyHash, image: Image, timeout: PosixTime ) derives FromData, ToData // Redeemer enum Action derives FromData, ToData: case Timeout case Reveal(preimage: Preimage) /** A Hash Time-Locked Contract (HTLC) validator. * * The HTLC allows a receiver to claim funds by revealing a preimage of a hash before a timeout, or * allows the committer to reclaim the funds after the timeout. * * @see * https://github.com/blockchain-unica/rosetta-smart-contracts/tree/main/contracts/htlc */ @Compile object HtlcValidator { inline def validate(scData: Data): Unit = { val ctx = scData.to[ScriptContext] ctx.scriptInfo match case ScriptInfo.SpendingScript(txOutRef, datum) => spend(datum, ctx.redeemer, ctx.txInfo, txOutRef) case _ => fail(MustBeSpending) } /** Spending script purpose validation */ inline def spend( datum: Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val config = datum.getOrFail(InvalidDatum).to[Config] redeemer.to[Action] match case Action.Timeout => val validFrom = tx.validRange.from.finite(0) // validFrom is inclusive, hence 10 <= 10 is correct require(config.timeout <= validFrom, InvalidCommitterTimePoint) require(tx.isSignedBy(config.committer), UnsignedCommitterTransaction) case Action.Reveal(preimage) => val validTo = tx.validRange.to.finiteOrFail(ValidRangeMustBeBound) // validTo is exclusive, hence 10 <= 10 is correct require(validTo <= config.timeout, InvalidReceiverTimePoint) require(tx.isSignedBy(config.receiver), UnsignedReceiverTransaction) require(sha3_256(preimage) == config.image, InvalidReceiverPreimage) } // Error messages inline val MustBeSpending = "Must be a spending script" inline val InvalidDatum = "Invalid Datum" inline val ValidRangeMustBeBound = "ValidTo must be set" inline val UnsignedCommitterTransaction = "Must be signed by a committer" inline val UnsignedReceiverTransaction = "Must be signed by a receiver" inline val InvalidCommitterTimePoint = "Must be exclusively after timeout" inline val InvalidReceiverTimePoint = "Must be inclusively before timeout" inline val InvalidReceiverPreimage = "Invalid preimage" } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/htlc/HtlcTest.scala ```scala package scalus.examples.htlc import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.Builtins.sha3_256 import scalus.uplc.builtin.Data.toData import scalus.cardano.ledger.* import scalus.cardano.node.Emulator import scalus.cardano.txbuilder.RedeemerPurpose.ForSpend import scalus.cardano.txbuilder.txBuilder import scalus.testing.kit.Party.{Alice, Bob, Eve} import scalus.testing.kit.TestUtil.getScriptContextV3 import scalus.testing.kit.{ScalusTest, TestUtil} import scalus.utils.await import java.time.Instant import scala.util.Try class HtlcTest extends AnyFunSuite, ScalusTest { private given env: CardanoInfo = TestUtil.testEnvironment private val contract = HtlcContract.compiled.withErrorTraces /** Transaction creator with real script evaluation */ private val txCreator = HtlcTransactions( env = env, contract = contract ) private val slot: SlotNo = 10 private val beforeSlot: SlotNo = slot - 1 private val afterSlot: SlotNo = slot + 1 private val timeout: Instant = env.slotConfig.slotToInstant(slot) private val beforeTimeout: Instant = env.slotConfig.slotToInstant(beforeSlot) private val afterTimeout: Instant = env.slotConfig.slotToInstant(afterSlot) val validPreimage: Preimage = genByteStringOfN(32).sample.get val wrongPreimage: Preimage = genByteStringOfN(12).sample.get private val image: Image = sha3_256(validPreimage) private def createProvider: Emulator = Emulator.withAddresses(Seq(Alice.address, Bob.address, Eve.address)) private def lock(provider: Emulator): Utxo = { val utxos = provider.findUtxos(address = Alice.address).await().toOption.get val lockTx = txCreator.lock( utxos = utxos, value = Value.ada(10), sponsor = Alice.address, committer = Alice.addrKeyHash, receiver = Bob.addrKeyHash, image = image, timeout = timeout, signer = Alice.signer ) assert(provider.submit(lockTx).await().isRight) val lockedUtxo = lockTx.utxos.find { case (_, txOut) => txOut.address == contract.address(env.network) }.get Utxo(lockedUtxo) } test(s"HTLC validator size is ${HtlcContract.compiled.script.script.size} bytes") { assert(HtlcContract.compiled.script.script.size == 387) } test("VALIDATOR: receiver reveals preimage before timeout") { val provider = createProvider val lockedUtxo = lock(provider) val utxos = provider.utxos val scriptCtx = txBuilder .spend( lockedUtxo, redeemer = Action.Reveal(validPreimage), script = contract.script ) .requireSignature(Bob.addrKeyHash) .payTo(Bob.address, Value.ada(10)) .validTo(timeout) .draft .getScriptContextV3(utxos, ForSpend(lockedUtxo.input)) assert(Try(contract(scriptCtx.toData).code).isSuccess) val result = contract(scriptCtx.toData).program.evaluateDebug assert(result.isSuccess) assert(result.budget == ExUnits(memory = 30555, steps = 12_330878)) assert(result.budget.fee == Coin(2653)) } test("receiver reveals preimage before timeout") { val provider = createProvider val lockedUtxo = lock(provider) val utxos = provider.findUtxos(Bob.address).await().toOption.get val revealTx = txCreator.reveal( utxos = utxos, lockedUtxo = lockedUtxo, payeeAddress = Bob.address, sponsor = Bob.address, preimage = validPreimage, receiverPkh = Bob.addrKeyHash, validTo = timeout, signer = Bob.signer ) assertResult(ExUnits(memory = 30555, steps = 12_330878)): revealTx.witnessSet.redeemers.get.value.totalExUnits provider.setSlot(beforeSlot) val result = provider.submit(revealTx).await() assert(result.isRight, s"Emulator submission failed: $result") } test("receiver fails with wrong preimage") { val provider = createProvider val lockedUtxo = lock(provider) val utxos = provider.findUtxos(Bob.address).await().toOption.get assertScriptFail(HtlcValidator.InvalidReceiverPreimage) { txCreator.reveal( utxos = utxos, lockedUtxo = lockedUtxo, payeeAddress = Bob.address, sponsor = Bob.address, preimage = wrongPreimage, receiverPkh = Bob.addrKeyHash, validTo = timeout, signer = Bob.signer ) } } test("receiver fails with wrong receiver pubkey hash") { val provider = createProvider val lockedUtxo = lock(provider) val utxos = provider.findUtxos(Eve.address).await().toOption.get assertScriptFail(HtlcValidator.UnsignedReceiverTransaction) { txCreator.reveal( utxos = utxos, lockedUtxo = lockedUtxo, payeeAddress = Eve.address, sponsor = Eve.address, preimage = validPreimage, receiverPkh = Eve.addrKeyHash, // Wrong receiver PKH (should be Bob) validTo = timeout, signer = Eve.signer ) } } test("receiver fails after timeout") { val provider = createProvider val lockedUtxo = lock(provider) val utxos = provider.findUtxos(Bob.address).await().toOption.get assertScriptFail(HtlcValidator.InvalidReceiverTimePoint) { txCreator.reveal( utxos = utxos, lockedUtxo = lockedUtxo, payeeAddress = Bob.address, sponsor = Bob.address, preimage = validPreimage, receiverPkh = Bob.addrKeyHash, validTo = afterTimeout, signer = Bob.signer ) } } test("committer reclaims after timeout") { val provider = createProvider val lockedUtxo = lock(provider) val utxos = provider.findUtxos(Alice.address).await().toOption.get val timeoutTx = txCreator.timeout( utxos = utxos, lockedUtxo = lockedUtxo, payeeAddress = Alice.address, sponsor = Alice.address, committerPkh = Alice.addrKeyHash, validFrom = afterTimeout, signer = Alice.signer ) assertResult(ExUnits(memory = 27122L, steps = 9441707L)): timeoutTx.witnessSet.redeemers.get.value.totalExUnits provider.setSlot(afterSlot) val submissionResult = provider.submit(timeoutTx).await() assert(submissionResult.isRight, s"Emulator submission failed: $submissionResult") } test("committer fails with wrong committer pubkey hash") { val provider = createProvider val lockedUtxo = lock(provider) val utxos = provider.findUtxos(Eve.address).await().toOption.get assertScriptFail(HtlcValidator.UnsignedCommitterTransaction) { txCreator.timeout( utxos = utxos, lockedUtxo = lockedUtxo, payeeAddress = Eve.address, sponsor = Eve.address, committerPkh = Eve.addrKeyHash, // Wrong committer PKH (should be Alice) validFrom = afterTimeout, signer = Eve.signer ) } } test("committer fails before timeout") { val provider = createProvider val lockedUtxo = lock(provider) val utxos = provider.findUtxos(Alice.address).await().toOption.get assertScriptFail(HtlcValidator.InvalidCommitterTimePoint) { txCreator.timeout( utxos = utxos, lockedUtxo = lockedUtxo, payeeAddress = Alice.address, sponsor = Alice.address, committerPkh = Alice.addrKeyHash, validFrom = beforeTimeout, signer = Alice.signer ) } } } ``` # Example: amm ## scalus-examples/jvm/src/main/scala/scalus/examples/amm/AmmContract.scala ```scala package scalus.examples.amm import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 import scalus.uplc.builtin.Data /** Blueprint and compiled script for the constant-product AMM. */ object AmmContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(AmmValidator.validate) lazy val blueprint = Blueprint.plutusV3[AmmParams, AmmDatum, AmmRedeemer]( title = "Constant-product AMM", description = "Constant-product (x*y=k) automated market maker parameterized by the token pair and " + "fee rate. Supports deposit, redeem and swap while binding the datum reserves to the " + "tokens actually held by the continuing pool output.", version = "1.0.0", license = Some("Apache-2.0"), // AmmValidator is a DataParameterizedValidator: the AmmParams parameter is applied as Data on // the UPLC level, so `compiled` is typed `Data => Data => Unit`. The cast only re-labels the // phantom type so the parameter schema is derived as AmmParams; the program is unchanged. compiled = compiled.asInstanceOf[PlutusV3[AmmParams => Data => Unit]] ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/amm/AmmOffchain.scala ```scala package scalus.examples.amm import scalus.cardano.address.Address import scalus.cardano.ledger.* import scalus.cardano.onchain.plutus.prelude.Math import scalus.cardano.txbuilder.* import scalus.uplc.PlutusV3 import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.Data /** Off-chain utilities for interacting with the AMM contract. */ case class AmmOffchain( env: CardanoInfo, evaluator: PlutusScriptEvaluator, contract: PlutusV3[Data => Data => Unit], params: AmmParams ) { private val appliedScript: PlutusV3[Data => Unit] = contract.apply(params.toData) val script: Script.PlutusV3 = appliedScript.script val policyId: PolicyId = appliedScript.script.scriptHash val scriptAddress: Address = appliedScript.address(env.network) val lpAssetName: AssetName = AssetName.fromString("lp") def readPoolDatum(utxo: Utxo): AmmDatum = utxo.output.inlineDatum .getOrElse(throw new Exception(s"Pool UTxO has no inline datum: $utxo")) .to[AmmDatum] def findPool(utxos: Iterable[Utxo]): Utxo = utxos .find(_.output.address == scriptAddress) .getOrElse(throw new Exception("Pool UTxO not found")) /** Constructs the on-chain [[Value]] for the pool output from explicit reserves. */ private def poolValue(r0: BigInt, r1: BigInt, lovelace: Long): Value = { val (p0, n0) = params.t0 val (p1, n1) = params.t1 Value.assets( Map( ScriptHash.fromArray(p0.bytes) -> Map(AssetName(n0) -> r0.toLong), ScriptHash.fromArray(p1.bytes) -> Map(AssetName(n1) -> r1.toLong) ), lovelace = Coin(lovelace) ) } /** Initializes an AMM with an empty pool and lp = 0. */ def createEmptyPool( utxos: Utxos, lovelace: Long, sponsor: Address, signer: TransactionSigner ): Transaction = { val emptyDatum = AmmDatum(r0 = BigInt(0), r1 = BigInt(0), lpSupply = BigInt(0)) TxBuilder(env, evaluator) .payTo(scriptAddress, Value.lovelace(lovelace), emptyDatum) .complete(utxos, sponsor) .sign(signer) .transaction } /** Deposits `x0` of t0 and `x1` of t1 into the pool, minting LP tokens to the sender. * * If the pool is empty (lpSupply == 0), mints `sqrt(x0 * x1)` lp tokens. On subsequent * deposits, the ratio `x0 / x1` must match the current reserves and LP tokens are minted * proportionally. */ def deposit( utxos: Utxos, poolUtxo: Utxo, x0: Long, x1: Long, sponsor: Address, signer: TransactionSigner ): Transaction = { val d = readPoolDatum(poolUtxo) val lpMinted: Long = if d.lpSupply == BigInt(0) then Math.sqrt(BigInt(x0) * BigInt(x1)).toLong else { val lp0 = (BigInt(x0) * d.lpSupply / d.r0).toLong val lp1 = (BigInt(x1) * d.lpSupply / d.r1).toLong lp0 min lp1 } val newDatum = AmmDatum( r0 = d.r0 + BigInt(x0), r1 = d.r1 + BigInt(x1), lpSupply = d.lpSupply + BigInt(lpMinted) ) val newValue = poolValue(newDatum.r0, newDatum.r1, poolUtxo.output.value.coin.value) val spendRedeemer = AmmRedeemer.Deposit(BigInt(x0), BigInt(x1)).toData val mintRedeemer = ().toData TxBuilder(env, evaluator) .spend(poolUtxo, _ => spendRedeemer, script) .mint(script, Map(lpAssetName -> lpMinted), _ => mintRedeemer) .payTo(scriptAddress, newValue, newDatum) .complete(utxos, sponsor) .sign(signer) .transaction } /** Burns lp tokens and returns proportional amounts of t0 and t1 to the `sponsor`. */ def redeem( utxos: Utxos, poolUtxo: Utxo, lp: Long, sponsor: Address, signer: TransactionSigner ): Transaction = { val d = readPoolDatum(poolUtxo) val out0 = (BigInt(lp) * d.r0 / d.lpSupply).toLong val out1 = (BigInt(lp) * d.r1 / d.lpSupply).toLong val newDatum = AmmDatum( r0 = d.r0 - BigInt(out0), r1 = d.r1 - BigInt(out1), lpSupply = d.lpSupply - BigInt(lp) ) val newValue = poolValue(newDatum.r0, newDatum.r1, poolUtxo.output.value.coin.value) val spendRedeemer = AmmRedeemer.Redeem(BigInt(lp)).toData val mintRedeemer = ().toData TxBuilder(env, evaluator) .spend(poolUtxo, _ => spendRedeemer, script) .mint(script, Map(lpAssetName -> -lp), _ => mintRedeemer) .payTo(scriptAddress, newValue, newDatum) .complete(utxos, sponsor) .sign(signer) .transaction } /** Swaps `amountIn` of one token for the other, subject to `minAmountOut` slippage protection. */ def swap( utxos: Utxos, poolUtxo: Utxo, t0In: Boolean, amountIn: Long, minAmountOut: Long, sponsor: Address, signer: TransactionSigner ): Transaction = { val d = readPoolDatum(poolUtxo) val dxAdj = BigInt(amountIn) * params.feeNumerator val (_, newR0, newR1) = if t0In then val out = d.r1 * dxAdj / (d.r0 * params.feeDenominator + dxAdj) (out, d.r0 + BigInt(amountIn), d.r1 - out) else val out = d.r0 * dxAdj / (d.r1 * params.feeDenominator + dxAdj) (out, d.r0 - out, d.r1 + BigInt(amountIn)) val newDatum = AmmDatum(r0 = newR0, r1 = newR1, lpSupply = d.lpSupply) val newValue = poolValue(newDatum.r0, newDatum.r1, poolUtxo.output.value.coin.value) val spendRedeemer = AmmRedeemer.Swap(t0In, BigInt(amountIn), BigInt(minAmountOut)).toData TxBuilder(env, evaluator) .spend(poolUtxo, _ => spendRedeemer, script) .payTo(scriptAddress, newValue, newDatum) .complete(utxos, sponsor) .sign(signer) .transaction } /** Returns `(amountOut, priceImpact)` for a hypothetical swap. * * `priceImpact` is in `[0, 1]`; multiply by 100 for a percentage. */ def swapQuote(pool: Utxo, t0In: Boolean, amountIn: Long): (Long, BigDecimal) = { val d = readPoolDatum(pool) val dxAdj = BigInt(amountIn) * params.feeNumerator val amountOut = if t0In then d.r1 * dxAdj / (d.r0 * params.feeDenominator + dxAdj) else d.r0 * dxAdj / (d.r1 * params.feeDenominator + dxAdj) val (reserveIn, reserveOut) = if t0In then (d.r0, d.r1) else (d.r1, d.r0) val midPrice = BigDecimal(reserveOut) / BigDecimal(reserveIn) val executionPrice = BigDecimal(amountOut) / BigDecimal(amountIn) val priceImpact = (midPrice - executionPrice) / midPrice (amountOut.toLong, priceImpact) } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/amm/AmmValidator.scala ```scala package scalus.examples.amm import scalus.compiler.Compile import scalus.uplc.builtin.{Data, FromData, ToData} import scalus.cardano.onchain.plutus.v1.{PolicyId, TokenName, Value} import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v2 import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* type TradedToken = (PolicyId, TokenName) /** Validator parameter: identifies the token pair and fee rate. */ case class AmmParams( t0: TradedToken, t1: TradedToken, feeNumerator: BigInt, feeDenominator: BigInt ) derives FromData, ToData case class AmmDatum( r0: BigInt, r1: BigInt, lpSupply: BigInt ) derives FromData, ToData @Compile object AmmDatum { given Eq[AmmDatum] = Eq.structural: (a: AmmDatum, b: AmmDatum) => a.r0 === b.r0 && a.r1 === b.r1 && a.lpSupply === b.lpSupply } /** Redeemer for the spending validator. */ enum AmmRedeemer derives FromData, ToData: case Deposit(x0: BigInt, x1: BigInt) case Redeem(lp: BigInt) case Swap(t0In: Boolean, amountIn: BigInt, minAmountOut: BigInt) /** Single-script AMM validator — acts as both pool spending validator and LP minting policy. * * The `policyId` of the LP token equals the `scriptHash` of this validator. The minting endpoint * only verifies that the minted/burned LP delta matches `lpSupply' - lpSupply` in the pool's * output datum. All invariant checks are performed by the spending endpoint. */ @Compile object AmmValidator extends DataParameterizedValidator { /** Reads the [[AmmDatum]] from an output's inline datum; fails otherwise. */ inline def readPoolDatum(out: TxOut): AmmDatum = out.datum match case OutputDatum.OutputDatum(d) => d.to[AmmDatum] case _ => fail("Pool output must have inline datum") /** Finds the unique pool output at `addr`; fails if absent or ambiguous. */ inline def findPoolOutput(outputs: List[TxOut], addr: Address): TxOut = { val matching = outputs.filter(_.address === addr) matching match case List.Cons(out, List.Nil) => out case List.Nil => fail("No pool output found") case _ => fail("Multiple pool outputs found") } // mints LP tokens inline def mint(param: Data, redeemer: Data, policyId: PolicyId, tx: TxInfo): Unit = { // Locate the pool input that we're spending val poolInputOpt = tx.inputs.find { inp => inp.resolved.address.credential match case Credential.ScriptCredential(sh) => sh === policyId case _ => false } val poolInput = poolInputOpt.getOrFail("Mint: no pool input found") val poolDatum = readPoolDatum(poolInput.resolved) val poolAddress = poolInput.resolved.address val continuationOut = findPoolOutput(tx.outputs, poolAddress) val continuationDatum = readPoolDatum(continuationOut) // LP delta must match actual minted/burned amount for this policyId. val lpDelta = continuationDatum.lpSupply - poolDatum.lpSupply val actualDelta = tx.mint.tokens(policyId).toList.foldLeft(BigInt(0)) { (acc, pair) => acc + pair._2 } require(actualDelta === lpDelta, "Mint: LP delta mismatch") } inline def spend( param: Data, d: Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val params = param.to[AmmParams] val action = redeemer.to[AmmRedeemer] val ownInput = tx.findOwnInputOrFail(ownRef, "Own pool input not found") val poolAddress = ownInput.resolved.address val datum = d.getOrFail("Pool datum missing").to[AmmDatum] val poolOutput = findPoolOutput(tx.outputs, poolAddress) val newDatum = readPoolDatum(poolOutput) action match { case AmmRedeemer.Deposit(x0, x1) => handleDeposit(params, datum, newDatum, x0, x1) case AmmRedeemer.Redeem(lp) => handleRedeem(datum, newDatum, lp) case AmmRedeemer.Swap(t0In, amountIn, minAmountOut) => handleSwap(params, datum, newDatum, t0In, amountIn, minAmountOut) } // Bind the datum reserves to the tokens actually held by the continuing pool output. // The handlers above only check the datum arithmetic; without this an attacker can write a // valid-looking datum while sending the real reserve tokens elsewhere, draining the pool. require( poolOutput.value.quantityOf(params.t0._1, params.t0._2) === newDatum.r0, ReserveT0Mismatch ) require( poolOutput.value.quantityOf(params.t1._1, params.t1._2) === newDatum.r1, ReserveT1Mismatch ) } private inline def handleDeposit( params: AmmParams, datum: AmmDatum, newDatum: AmmDatum, x0: BigInt, x1: BigInt ): Unit = { require(x0 > BigInt(0) && x1 > BigInt(0), "Deposit: amounts must be positive") val lpMinted = if datum.lpSupply === BigInt(0) then Math.sqrt(x0 * x1) else { require(x0 * datum.r1 === x1 * datum.r0, "Deposit: ratio mismatch") val lp0 = x0 * datum.lpSupply / datum.r0 val lp1 = x1 * datum.lpSupply / datum.r1 Math.min(lp0, lp1) } require(lpMinted > BigInt(0), "Deposit: zero LP minted") val expectedDatum = AmmDatum( r0 = datum.r0 + x0, r1 = datum.r1 + x1, lpSupply = datum.lpSupply + lpMinted ) require(newDatum === expectedDatum, "Deposit: output datum mismatch") } private inline def handleRedeem( datum: AmmDatum, newDatum: AmmDatum, lp: BigInt ): Unit = { // We don't check where the redeemed tokens go: phase-1 already guarantees the tx balances, // and the caller (`spend`) binds the new datum reserves to the continuing pool output's // actual token quantities, so the pool cannot be under-funded. We only validate the datum // transition here. Similar reasoning applies in `handleSwap`. require(lp > BigInt(0), "Redeem: LP amount must be positive") require(lp <= datum.lpSupply, "Redeem: LP amount exceeds supply") val out0 = lp * datum.r0 / datum.lpSupply val out1 = lp * datum.r1 / datum.lpSupply val expectedDatum = AmmDatum( r0 = datum.r0 - out0, r1 = datum.r1 - out1, lpSupply = datum.lpSupply - lp ) require(newDatum === expectedDatum, "Redeem: output datum mismatch") } private inline def handleSwap( params: AmmParams, datum: AmmDatum, newDatum: AmmDatum, t0In: Boolean, amountIn: BigInt, minAmountOut: BigInt ): Unit = { // We don't check where the swapped-out tokens go: phase-1 already guarantees the tx // balances, and `spend` binds the new datum reserves to the continuing pool output's actual // token quantities, so the pool cannot be under-funded. We only validate the datum // transition here. Similar reasoning applies in `handleRedeem`. require(amountIn > BigInt(0), "Swap: amountIn must be positive") val dxAdjusted = amountIn * params.feeNumerator val (amountOut, newR0, newR1) = if t0In then val out = datum.r1 * dxAdjusted / (datum.r0 * params.feeDenominator + dxAdjusted) (out, datum.r0 + amountIn, datum.r1 - out) else val out = datum.r0 * dxAdjusted / (datum.r1 * params.feeDenominator + dxAdjusted) (out, datum.r0 - out, datum.r1 + amountIn) require(amountOut >= minAmountOut, "Swap: slippage exceeded") require(newR0 * newR1 >= datum.r0 * datum.r1, "Swap: invariant violated") val expectedDatum = AmmDatum(r0 = newR0, r1 = newR1, lpSupply = datum.lpSupply) require(newDatum === expectedDatum, "Swap: output datum mismatch") } private inline val ReserveT0Mismatch = "Pool output must hold r0 of token0" private inline val ReserveT1Mismatch = "Pool output must hold r1 of token1" } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/amm/AmmTest.scala ```scala package scalus.examples.amm import org.scalacheck.Gen import org.scalatest.funsuite.AnyFunSuite import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.{Context, PlutusScriptsTransactionMutator} import scalus.cardano.node.Emulator import scalus.cardano.txbuilder.TxBuilder import scalus.testing.kit.Party.{Alice, Bob} import scalus.testing.kit.ScalusTest import scalus.testing.kit.TestUtil.genesisHash import scalus.uplc.builtin.ByteString import scalus.uplc.builtin.Data.toData import scalus.utils.await class AmmTest extends AnyFunSuite, ScalusTest, ScalaCheckPropertyChecks { import AmmTest.{*, given} test(s"AmmValidator size: ${AmmContract.compiled.script.script.size} bytes") { info(s"Validator size: ${AmmContract.compiled.script.script.size} bytes") } test("createPool: creates empty pool with zero reserves") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val tx = txCreator.createEmptyPool(utxos, 5_000_000L, Alice.address, Alice.signer) assert(provider.submit(tx).await().isRight, "createPool should succeed") val poolUtxo = Utxo(tx.utxos.find(_._2.address == txCreator.scriptAddress).get) val datum = txCreator.readPoolDatum(poolUtxo) assert(datum == AmmDatum(BigInt(0), BigInt(0), BigInt(0))) } test("deposit to an empty pool mints LP = isqrt(x0 * x1)") { val (provider, txCreator) = createSetup() val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val createTx = txCreator.createEmptyPool(utxos1, 5_000_000L, Alice.address, Alice.signer) provider.submit(createTx).await() val poolUtxo = Utxo(createTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val depositTx = txCreator.deposit(utxos2, poolUtxo, x0 = 1000L, x1 = 4000L, Alice.address, Alice.signer) assert(provider.submit(depositTx).await().isRight, "first deposit should succeed") val newPool = Utxo(depositTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val datum = txCreator.readPoolDatum(newPool) assert(datum.r0 == BigInt(1000)) assert(datum.r1 == BigInt(4000)) assert(datum.lpSupply == BigInt(Math.sqrt(1000 * 4000).toInt)) } test("deposit to a non-empty pool mints LP proportionally, maintains the constant product") { val (provider, txCreator) = createSetup() val poolUtxo1 = initPoolWith(provider, txCreator, x0 = 1000L, x1 = 4000L) val utxos = provider.findUtxos(Alice.address).await().toOption.get val depositTx = txCreator.deposit(utxos, poolUtxo1, x0 = 1000L, x1 = 4000L, Alice.address, Alice.signer) assert(provider.submit(depositTx).await().isRight, "second deposit should succeed") val newPool = Utxo(depositTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val datum = txCreator.readPoolDatum(newPool) assert(datum.r0 == BigInt(2000)) assert(datum.r1 == BigInt(8000)) assert(datum.lpSupply == BigInt(4000)) // doubled LP supply } test("redeem: burns LP, receives proportional t0 and t1") { val (provider, txCreator) = createSetup() val poolUtxo1 = initPoolWith(provider, txCreator, x0 = 1000L, x1 = 4000L) val initialDatum = txCreator.readPoolDatum(poolUtxo1) val utxos = provider.findUtxos(Alice.address).await().toOption.get // Redeem half the LP supply val halfLp = (initialDatum.lpSupply / 2).toLong val redeemTx = txCreator.redeem(utxos, poolUtxo1, halfLp, Alice.address, Alice.signer) assert(provider.submit(redeemTx).await().isRight, "redeem should succeed") val newPool = Utxo(redeemTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val newDatum = txCreator.readPoolDatum(newPool) assert(newDatum.lpSupply == initialDatum.lpSupply - BigInt(halfLp)) assert( newDatum.r0 == initialDatum.r0 - (BigInt( halfLp ) * initialDatum.r0 / initialDatum.lpSupply) ) assert( newDatum.r1 == initialDatum.r1 - (BigInt( halfLp ) * initialDatum.r1 / initialDatum.lpSupply) ) } test("swap t0 to t1 correctly") { val (provider, txCreator) = createSetup() val poolUtxo = initPoolWith(provider, txCreator, x0 = 10_000L, x1 = 40_000L) val d = txCreator.readPoolDatum(poolUtxo) val amountIn = 1000L val (expectedOut, _) = txCreator.swapQuote(poolUtxo, t0In = true, amountIn) val utxos = provider.findUtxos(Alice.address).await().toOption.get val swapTx = txCreator.swap( utxos, poolUtxo, t0In = true, amountIn, minAmountOut = 1L, Alice.address, Alice.signer ) assert(provider.submit(swapTx).await().isRight, "swap t0 to t1 should succeed") val newPool = Utxo(swapTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val newDatum = txCreator.readPoolDatum(newPool) assert(newDatum.r0 == d.r0 + BigInt(amountIn)) assert(newDatum.r1 == d.r1 - BigInt(expectedOut)) assert(newDatum.lpSupply == d.lpSupply) // k-invariant assert(newDatum.r0 * newDatum.r1 >= d.r0 * d.r1, "k-invariant must hold") } test("swap t1 to t0: correct output, datum updated, invariant holds") { val (provider, txCreator) = createSetup() val poolUtxo = initPoolWith(provider, txCreator, x0 = 10_000L, x1 = 40_000L) val d = txCreator.readPoolDatum(poolUtxo) val amountIn = 4000L val (expectedOut, _) = txCreator.swapQuote(poolUtxo, t0In = false, amountIn) val utxos = provider.findUtxos(Alice.address).await().toOption.get val swapTx = txCreator.swap( utxos, poolUtxo, t0In = false, amountIn, minAmountOut = 1L, Alice.address, Alice.signer ) assert(provider.submit(swapTx).await().isRight, "swap t1 to t0 should succeed") val newPool = Utxo(swapTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val newDatum = txCreator.readPoolDatum(newPool) assert(newDatum.r1 == d.r1 + BigInt(amountIn)) assert(newDatum.r0 == d.r0 - BigInt(expectedOut)) assert(newDatum.lpSupply == d.lpSupply) assert(newDatum.r0 * newDatum.r1 >= d.r0 * d.r1, "k-invariant must hold") } test("FAIL: deposit with wrong ratio") { val (provider, txCreator) = createSetup() val poolUtxo = initPoolWith(provider, txCreator, x0 = 1000L, x1 = 4000L) val (_, badTxCreator) = createSetup(EvaluatorMode.Validate) val utxos = provider.findUtxos(Alice.address).await().toOption.get val badTx = badTxCreator.deposit( utxos, poolUtxo, x0 = 1000L, x1 = 1000L, // 1000/1000 != initial 1000/4000 Alice.address, Alice.signer ) assertSubmitFails(provider, badTx) } test("FAIL: swap below minAmountOut") { val (provider, txCreator) = createSetup() val poolUtxo = initPoolWith(provider, txCreator, x0 = 10_000L, x1 = 40_000L) val (_, badTxCreator) = createSetup(EvaluatorMode.Validate) val utxos = provider.findUtxos(Alice.address).await().toOption.get val badTx = badTxCreator.swap( utxos, poolUtxo, t0In = true, 1000L, minAmountOut = 999_999L, // too large Alice.address, Alice.signer ) assertSubmitFails(provider, badTx) } test("FAIL: swap that drains the reserve tokens from the continuation output") { val (provider, txCreator) = createSetup() val poolUtxo = initPoolWith(provider, txCreator, x0 = 10_000L, x1 = 40_000L) val d = txCreator.readPoolDatum(poolUtxo) // Honest swap math, so the datum transition check passes... val amountIn = 1000L val dxAdj = BigInt(amountIn) * ammParams.feeNumerator val out = d.r1 * dxAdj / (d.r0 * ammParams.feeDenominator + dxAdj) val newDatum = AmmDatum(d.r0 + BigInt(amountIn), d.r1 - out, d.lpSupply) // ...but the continuation output is emptied of the reserve tokens (only min ADA), so the // real T0/T1 reserves flow to the attacker as change. A validator that inspects only the // datum lets this through — draining the pool. val drainedValue = Value.lovelace(2_000_000L) val utxos = provider.findUtxos(Alice.address).await().toOption.get // Build with constMaxBudget so the script isn't evaluated at build time; the validator // failure then surfaces at submission (like the other FAIL tests). val drainTx = TxBuilder(env, PlutusScriptEvaluator.constMaxBudget(env)) .spend( poolUtxo, _ => AmmRedeemer.Swap(true, BigInt(amountIn), BigInt(1)).toData, txCreator.script ) .payTo(txCreator.scriptAddress, drainedValue, newDatum) .complete(utxos, Alice.address) .sign(Alice.signer) .transaction assertSubmitFails(provider, drainTx) } test("property: k-invariant holds for all valid swaps") { forAll(swapGen) { (r0, r1, amountIn, t0In) => val (provider, txCreator) = createSetup() val poolUtxo = initPoolWith(provider, txCreator, x0 = r0, x1 = r1) val d = txCreator.readPoolDatum(poolUtxo) val (amountOut, _) = txCreator.swapQuote(poolUtxo, t0In, amountIn) whenever(amountOut > 0) { val (newR0, newR1) = if t0In then (d.r0 + BigInt(amountIn), d.r1 - BigInt(amountOut)) else (d.r0 - BigInt(amountOut), d.r1 + BigInt(amountIn)) assert(newR0 * newR1 >= d.r0 * d.r1, "k-invariant must hold after swap") } } } test("property: deposit-then-redeem yields back at most what was deposited") { // Generate a scale factor k and deposit exactly (k*r0, k*r1) so the ratio check // x0 * r1 == x1 * r0 holds by construction (k*r0 * r1 == k*r1 * r0). forAll(depositGen) { (r0, r1, k) => val x0 = r0 * k val x1 = r1 * k val (provider, txCreator) = createSetup() val poolUtxo = initPoolWith(provider, txCreator, x0 = r0, x1 = r1) val dBefore = txCreator.readPoolDatum(poolUtxo) val utxos = provider.findUtxos(Alice.address).await().toOption.get val depositTx = txCreator.deposit(utxos, poolUtxo, x0, x1, Alice.address, Alice.signer) val depositOk = provider.submit(depositTx).await() whenever(depositOk.isRight) { val afterDeposit = Utxo(depositTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val dAfterDeposit = txCreator.readPoolDatum(afterDeposit) val lpMinted = (dAfterDeposit.lpSupply - dBefore.lpSupply).toLong val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val redeemTx = txCreator.redeem(utxos2, afterDeposit, lpMinted, Alice.address, Alice.signer) val redeemOk = provider.submit(redeemTx).await() whenever(redeemOk.isRight) { val afterRedeem = Utxo(redeemTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val dAfterRedeem = txCreator.readPoolDatum(afterRedeem) val got0 = (dAfterDeposit.r0 - dAfterRedeem.r0).toLong val got1 = (dAfterDeposit.r1 - dAfterRedeem.r1).toLong assert(got0 <= x0, s"got back more t0 than deposited: $got0 > $x0") assert(got1 <= x1, s"got back more t1 than deposited: $got1 > $x1") } } } } } object AmmTest extends ScalusTest { given env: CardanoInfo = scalus.testing.kit.TestUtil.testEnvironment val t0PolicyId: PolicyId = ScriptHash.fromArray(Array.fill(28)(0x01.toByte)) val t1PolicyId: PolicyId = ScriptHash.fromArray(Array.fill(28)(0x02.toByte)) val t0Name: AssetName = AssetName(ByteString.fromString("T0")) val t1Name: AssetName = AssetName(ByteString.fromString("T1")) val ammParams: AmmParams = AmmParams( t0 = (t0PolicyId, t0Name.bytes), t1 = (t1PolicyId, t1Name.bytes), feeNumerator = BigInt(997), feeDenominator = BigInt(1000) ) private val compiledContract = AmmContract.compiled.withErrorTraces private def aliceTokenUtxos: Map[TransactionInput, TransactionOutput] = { val tokenValue = Value.asset(t0PolicyId, t0Name, 1_000_000L, Coin.ada(100)) + Value.asset(t1PolicyId, t1Name, 1_000_000L) Map( Input(genesisHash, 0) -> Output(Alice.address, Value.ada(10_000)), Input(genesisHash, 1) -> Output(Alice.address, Value.ada(10_000)), Input(genesisHash, 2) -> Output(Alice.address, tokenValue), Input(genesisHash, 3) -> Output(Alice.address, Value.ada(10_000)), Input(genesisHash, 4) -> Output(Bob.address, Value.ada(10_000)) ) } def createSetup( evaluatorMode: EvaluatorMode = EvaluatorMode.EvaluateAndComputeCost ): (Emulator, AmmOffchain) = { val evaluator = evaluatorMode match case EvaluatorMode.EvaluateAndComputeCost => PlutusScriptEvaluator(env, EvaluatorMode.EvaluateAndComputeCost) case _ => PlutusScriptEvaluator.constMaxBudget(env) val provider = Emulator( initialUtxos = aliceTokenUtxos, initialContext = Context.testMainnet().copy(evaluatorMode = EvaluatorMode.EvaluateAndComputeCost), mutators = Set(PlutusScriptsTransactionMutator) ) val txCreator = AmmOffchain( env = env, evaluator = evaluator, contract = compiledContract, params = ammParams ) (provider, txCreator) } // creates an empty pool and immediately makes a deposit tx with x0 and x1 def initPoolWith(provider: Emulator, txCreator: AmmOffchain, x0: Long, x1: Long): Utxo = { val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val createTx = txCreator.createEmptyPool(utxos1, 5_000_000L, Alice.address, Alice.signer) provider.submit(createTx).await() val emptyPool = Utxo(createTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val depositTx = txCreator.deposit(utxos2, emptyPool, x0, x1, Alice.address, Alice.signer) provider.submit(depositTx).await() Utxo(depositTx.utxos.find(_._2.address == txCreator.scriptAddress).get) } def assertSubmitFails(provider: Emulator, tx: Transaction): Unit = provider.submit(tx).await() match { case Left(_) => () case Right(_) => fail("Expected transaction submission to fail but it succeeded") } val swapGen: Gen[(Long, Long, Long, Boolean)] = for r0 <- Gen.chooseNum(10_000L, 1_000_000L) r1 <- Gen.chooseNum(10_000L, 1_000_000L) amountIn <- Gen.chooseNum(1L, r0 / 10) t0In <- Gen.oneOf(true, false) yield (r0, r1, amountIn, t0In) val depositGen: Gen[(Long, Long, Long)] = for r0 <- Gen.chooseNum(10L, 1_000L) r1 <- Gen.chooseNum(10L, 1_000L) k <- Gen.chooseNum(1L, 10L) yield (r0, r1, k) } ``` # Example: anonymousdata ## scalus-examples/jvm/src/main/scala/scalus/examples/anonymousdata/AnonymousDataTransactions.scala ```scala package scalus.examples.anonymousdata import scalus.cardano.onchain.plutus.prelude.List as PList import scalus.uplc.builtin.{ByteString, Data} import scalus.cardano.address.Address import scalus.cardano.ledger.* import scalus.cardano.txbuilder.* /** Anonymous on-chain data storage — implemented with **zero on-chain execution**. * * Specification (rosetta `anonymous_data`): "store data on-chain, associated with a cryptographic * hash, in a way that only the user who can generate that hash can retrieve it." * * On Cardano this needs no validator at all. Every transaction output can carry a **datum hash** * ([[scalus.cardano.ledger.DatumOption.Hash]]) — a 32-byte commitment whose preimage is NOT kept * on-chain. We commit to `Data.List([B(nonce), data])`: * * - The chain stores only `blake2b_256(serialise([nonce, data]))`. Observers see a hash, not the * data, and cannot tell two unrelated entries apart. * - To **retrieve** an entry you reveal the preimage `(nonce, data)`; anyone can recompute the * hash and check it against the on-chain UTxO. Only someone who already knows `(nonce, data)` * can produce a matching preimage. * - The `nonce` is what makes the commitment *hiding*: without it, low-entropy `data` (a vote, a * yes/no flag, a small number) could be brute-forced by hashing every candidate. A fresh * random nonce per entry also makes the same `data` stored twice produce two unlinkable * hashes. * * This is a commitment scheme expressed in a native ledger feature. It is the whole point of the * example: this functionality requires no smart contract — just a datum hash on an ordinary UTxO. * * Note on anonymity: the storing transaction is still signed by *some* key, so on a public chain a * determined observer can link the storer's wallet to the UTxO they created. Hiding *that* link is * a different problem (it needs a relayer plus a zero-knowledge membership proof, e.g. a bilinear * accumulator or zk-SNARK). What a datum hash gives you, simply and cheaply, is data * confidentiality with selective disclosure: the *contents* stay private until their owner chooses * to reveal them. */ case class AnonymousDataTransactions(env: CardanoInfo) { /** The committed preimage for `data` under a secret `nonce`: `Data.List([B(nonce), data])`. */ def commitment(nonce: ByteString, data: Data): Data = Data.List(PList.Cons(Data.B(nonce), PList.Cons(data, PList.Nil))) /** The on-chain footprint of an entry: `blake2b_256` of the CBOR-encoded commitment. */ def commitmentHash(nonce: ByteString, data: Data): DataHash = DataHash.fromByteString(commitment(nonce, data).dataHash) /** Store `data`: create a UTxO whose datum is committed *by hash only*. * * The preimage `(nonce, data)` never touches the chain — only its 32-byte hash does. The UTxO * sits at `owner`, so only the owner's key can later spend it; the data itself is recoverable * only by someone who knows the preimage. */ def store( utxos: Utxos, data: Data, nonce: ByteString, ada: Coin, owner: Address, changeAddress: Address, signer: TransactionSigner ): Transaction = TxBuilder(env) .payTo(owner, Value(ada), commitmentHash(nonce, data)) .complete(availableUtxos = utxos, changeAddress) .sign(signer) .transaction /** Retrieve (open) a stored entry off-chain. * * Given a revealed `(nonce, data)` and the stored UTxO, return the data iff the preimage * hashes to the UTxO's committed datum hash. Pure verification — no transaction, no script. * Retrieval never needs to touch the chain: the commitment is already there, and revealing the * preimage to any verifier proves what was stored. */ def open(storedUtxo: Utxo, nonce: ByteString, data: Data): Option[Data] = storedUtxo.output.datumOption match case Some(DatumOption.Hash(h)) if h == commitmentHash(nonce, data) => Some(data) case _ => None } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/anonymousdata/AnonymousDataTest.scala ```scala package scalus.examples.anonymousdata import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.ByteString.utf8 import scalus.uplc.builtin.Data import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.Context import scalus.cardano.node.Emulator import scalus.testing.kit.Party.Alice import scalus.testing.kit.ScalusTest import scalus.testing.kit.TestUtil.{genesisHash, testEnvironment} import scalus.utils.await class AnonymousDataTest extends AnyFunSuite with ScalusTest { private given env: CardanoInfo = testEnvironment private val txs = AnonymousDataTransactions(env) // A deliberately low-entropy secret — the kind that would be brute-forceable without a nonce. private val data: Data = Data.B(utf8"vote: yes") private val nonce = utf8"f3c1a9e07b2d48569a01ffbe2c7d3a64" private def createProvider(): Emulator = Emulator( initialUtxos = Map( Input(genesisHash, 0) -> TransactionOutput .Babbage(Alice.address, Value.ada(5000)), Input(genesisHash, 1) -> TransactionOutput .Babbage(Alice.address, Value.ada(5000)) ), initialContext = Context.testMainnet() ) private def storedUtxo(tx: Transaction, hash: DataHash): Utxo = tx.utxos .collectFirst { case entry @ (_, out) if out.datumOption.contains(DatumOption.Hash(hash)) => Utxo(entry) } .getOrElse( fail("Committed UTxO with the datum hash not found in the store transaction") ) test("store writes only the datum hash — the data itself is not on-chain") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val tx = txs.store( utxos, data, nonce, Coin(2_000_000L), Alice.address, Alice.address, Alice.signer ) val result = provider.submit(tx).await() assert(result.isRight, s"store should succeed: $result") val committed = storedUtxo(tx, txs.commitmentHash(nonce, data)) // Only the 32-byte hash is present; the preimage (the data) is nowhere on-chain. assert( committed.output.datumOption.contains(DatumOption.Hash(txs.commitmentHash(nonce, data))) ) assert(committed.output.inlineDatum.isEmpty, "the data must not be stored inline") } test("retrieve: only the correct (nonce, data) preimage opens the entry") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val tx = txs.store( utxos, data, nonce, Coin(2_000_000L), Alice.address, Alice.address, Alice.signer ) provider.submit(tx).await() val committed = storedUtxo(tx, txs.commitmentHash(nonce, data)) assert( txs.open(committed, nonce, data).contains(data), "correct preimage must open the entry" ) assert(txs.open(committed, utf8"wrong-nonce", data).isEmpty, "wrong nonce must not open it") assert( txs.open(committed, nonce, Data.B(utf8"vote: no")).isEmpty, "wrong data must not open it" ) } test("the nonce hides the data: same data under different nonces is unlinkable") { val h1 = txs.commitmentHash(utf8"nonce-1", data) val h2 = txs.commitmentHash(utf8"nonce-2", data) assert(h1 != h2, "the same data under different nonces must produce different hashes") // And the commitment is deterministic for a fixed preimage (so retrieval can verify it). assert(txs.commitmentHash(nonce, data) == txs.commitmentHash(nonce, data)) } } ``` # Example: atomictransactions ## scalus-examples/jvm/src/main/scala/scalus/examples/atomictransactions/AtomicTransactions.scala ```scala package scalus.examples.atomictransactions import scalus.cardano.address.Address import scalus.cardano.ledger.* import scalus.cardano.txbuilder.{TransactionSigner, TxBuilder} /** Illustrates Cardano's native transaction atomicity (rosetta `atomic_transactions`). * * On EVM chains, performing several actions atomically requires a contract that batches sub-calls * and rolls back on failure. On Cardano every transaction is atomic by the ledger rules: all * inputs are consumed and all outputs are created in a single step, or nothing changes at all. So * "batching" needs no contract — it is just spending several UTxOs in one transaction. */ case class AtomicTransactions(env: CardanoInfo) { /** Build one transaction that spends every UTxO in `senderUtxos` and pays `amount` to * `recipient`, returning change to `changeAddress`. * * Either all of those inputs are consumed and the payment is made, or the whole transaction is * rejected by the ledger — there is no partial outcome. That all-or-nothing guarantee is the * atomicity an EVM batching contract would have to implement by hand. */ def batchPay( senderUtxos: Utxos, recipient: Address, amount: Coin, changeAddress: Address, signer: TransactionSigner ): Transaction = senderUtxos .foldLeft(TxBuilder(env)) { case (builder, entry) => builder.spend(Utxo(entry)) } .payTo(recipient, Value(amount)) .complete(availableUtxos = senderUtxos, changeAddress) .sign(signer) .transaction } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/atomictransactions/AtomicTransactionsTest.scala ```scala package scalus.examples.atomictransactions import org.scalatest.funsuite.AnyFunSuite import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.Context import scalus.cardano.node.Emulator import scalus.testing.kit.Party.{Alice, Bob} import scalus.testing.kit.ScalusTest import scalus.testing.kit.TestUtil.{genesisHash, testEnvironment} import scalus.utils.await class AtomicTransactionsTest extends AnyFunSuite, ScalusTest { private given env: CardanoInfo = testEnvironment private val txs = AtomicTransactions(env) private def provider(): Emulator = Emulator( initialUtxos = Map( Input(genesisHash, 0) -> TransactionOutput .Babbage(Alice.address, Value.ada(10)), Input(genesisHash, 1) -> TransactionOutput .Babbage(Alice.address, Value.ada(10)) ), initialContext = Context.testMainnet() ) test("batchPay spends every sender UTxO in one atomic transaction") { val p = provider() val aliceUtxos = p.findUtxos(Alice.address).await().toOption.get assert(aliceUtxos.size == 2) val tx = txs.batchPay( senderUtxos = aliceUtxos, recipient = Bob.address, amount = Coin(5_000_000L), changeAddress = Alice.address, signer = Alice.signer ) // Both of Alice's UTxOs are inputs — they are consumed all-or-nothing. assert( aliceUtxos.keySet.forall(in => tx.body.value.inputs.toSeq.contains(in)), "every sender UTxO must be an input" ) assert(p.submit(tx).await().isRight, "atomic batch should submit") // Bob received exactly the payment. val bobPaid = tx.utxos.exists { case (_, out) => out.address == Bob.address && out.value.coin.value == 5_000_000L } assert(bobPaid, "Bob must receive exactly 5 ADA") } } ``` # Example: auction ## scalus-examples/jvm/src/main/scala/scalus/examples/auction/Auction.scala ```scala package scalus.examples.auction import scalus.compiler.Compile import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{ByteString, Data, ToData} import scalus.cardano.address.{Address as CardanoAddress, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart} import scalus.cardano.ledger.{AddrKeyHash, AssetName, CardanoInfo, Coin, Transaction, Utxo, Value as LedgerValue} import scalus.cardano.node.BlockchainProvider import scalus.cardano.txbuilder.{TransactionSigner, TxBuilder} import scalus.compiler.Options import scalus.cardano.onchain.plutus.v1.{Address, Credential, PubKeyHash} import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.v3.DataParameterizedValidator import scalus.uplc.PlutusV3 import java.time.Instant import scala.concurrent.Future /** Auction datum representing the state of an auction * @param seller * The public key hash of the seller * @param highestBidder * The current highest bidder (None if no bids yet) * @param highestBid * The current highest bid amount in lovelace * @param auctionEndTime * The POSIX time when the auction ends * @param itemId * The token name of the auction NFT */ case class Datum( seller: PubKeyHash, highestBidder: Option[PubKeyHash], highestBid: BigInt, auctionEndTime: PosixTime, itemId: ByteString ) derives Data.FromData, Data.ToData @Compile object Datum { given Eq[Datum] = Eq.derived } /** Auction, as described in Rosetta Smart Contracts: * https://github.com/blockchain-unica/rosetta-smart-contracts/tree/main/contracts/auction */ /** Actions that can be performed on the auction contract * * Bid and End actions include index parameters for O(1) UTxO lookups (indexed UTxO pattern). The * indices are computed off-chain using delayed redeemer pattern. */ enum Action derives Data.FromData, Data.ToData: case Start( itemId: ByteString, seller: PubKeyHash, startingBid: BigInt, auctionEndTime: PosixTime ) /** Place a bid on the auction * @param amount * The bid amount in lovelace * @param bidder * The bidder's public key hash * @param inputIdx * Index of the auction input in txInfo.inputs * @param outputIdx * Index of the continuing auction output in txInfo.outputs * @param refundOutputIdx * Index of refund output for previous bidder (-1 if no previous bidder) */ case Bid( amount: BigInt, bidder: PubKeyHash, inputIdx: BigInt, outputIdx: BigInt, refundOutputIdx: BigInt ) /** End the auction and transfer item to highest bidder, funds to seller * @param inputIdx * Index of the auction input in txInfo.inputs * @param sellerOutputIdx * Index of seller's payment output in txInfo.outputs * @param winnerOutputIdx * Index of winner's NFT output (-1 if no winner, seller reclaims) */ case End(inputIdx: BigInt, sellerOutputIdx: BigInt, winnerOutputIdx: BigInt) /** Auction validator parameterized by a one-shot UTxO reference. * * The oneShot TxOutRef must be spent when starting the auction, ensuring each auction instance has * a unique policyId/script hash. This prevents UTXO discovery confusion attacks where multiple * auctions could share the same itemId. * * Uses DataParameterizedValidator so the parameter is passed as Data and can be applied at * runtime. */ @Compile object AuctionValidator extends DataParameterizedValidator { inline override def spend( oneShotData: Data, @annotation.unused datum: Option[Data], redeemer: Data, txInfo: TxInfo, txOutRef: TxOutRef ): Unit = val oneShot = oneShotData.to[TxOutRef] // Match on redeemer action and extract input using provided index redeemer.to[Action] match case Action.Bid(bidAmount, bidder, inputIdx, outputIdx, refundOutputIdx) => // Use indexed lookup instead of searching val input = txInfo.inputs.at(inputIdx) require(input.outRef === txOutRef, "Input index does not match txOutRef") val (scriptHash, inputValue, currentDatum) = input.resolved match case TxOut( Address(Credential.ScriptCredential(sh), _), value, OutputDatum.OutputDatum(inlineDatum), _ ) => (sh, value, inlineDatum.to[Datum]) case _ => fail("Auction input must have script credential and inline datum") handleBid( txInfo, scriptHash, currentDatum, bidAmount, bidder, outputIdx, refundOutputIdx ) case Action.End(inputIdx, sellerOutputIdx, winnerOutputIdx) => // Use indexed lookup instead of searching val input = txInfo.inputs.at(inputIdx) require(input.outRef === txOutRef, "Input index does not match txOutRef") val (scriptHash, inputValue, currentDatum) = input.resolved match case TxOut( Address(Credential.ScriptCredential(sh), _), value, OutputDatum.OutputDatum(inlineDatum), _ ) => (sh, value, inlineDatum.to[Datum]) case _ => fail("Auction input must have script credential and inline datum") handleEnd(txInfo, scriptHash, currentDatum, sellerOutputIdx, winnerOutputIdx) case Action.Start(_, _, _, _) => fail("Start action is only valid for minting") /** Handle bid action using indexed UTxO pattern for O(1) lookups */ private inline def handleBid( txInfo: TxInfo, scriptHash: ValidatorHash, datum: Datum, bidAmount: BigInt, bidder: PubKeyHash, outputIdx: BigInt, refundOutputIdx: BigInt ): Unit = val Datum(seller, currentHighestBidder, currentHighestBid, auctionEndTime, itemId) = datum // 1. Time validation: bid must be before auction end require( txInfo.validRange.isEntirelyBefore(auctionEndTime), "Bid must be placed before auction ends" ) // 2. Bidder must sign the transaction require( txInfo.isSignedBy(bidder), "Bidder must sign the transaction" ) // 3. Bidder cannot be the seller (prevents self-bidding manipulation) require( !(bidder === seller), "Seller cannot bid on their own auction" ) // 4. New bid must be higher than current highest bid require( bidAmount > currentHighestBid, "Bid must be higher than current highest bid" ) // 5. Use indexed lookup for continuing output (O(1) instead of O(n)) val continuingOutput = txInfo.outputs.at(outputIdx) // 6. Verify continuing output goes to the same script address (prevents redirect attack) require( continuingOutput.address === Address.fromScriptHash(scriptHash), "Continuing output must go to auction script address" ) val newDatum = continuingOutput.datum match case OutputDatum.OutputDatum(newDatumData) => newDatumData.to[Datum] case _ => fail("Continuing auction output must have inline datum") // 7. Verify the new datum is correct val expectedNewDatum = Datum( seller = seller, highestBidder = Option.Some(bidder), highestBid = bidAmount, auctionEndTime = auctionEndTime, itemId = itemId ) require( newDatum === expectedNewDatum, "New datum must reflect the new bid" ) // 8. Verify the auction NFT is preserved in the continuing output require( continuingOutput.value.quantityOf(scriptHash, itemId) === BigInt(1), "Auction NFT must be preserved" ) // 9. Verify the continuing output has at least the bid amount in lovelace require( continuingOutput.value.getLovelace >= bidAmount, "Continuing output must contain at least the bid amount" ) // 10. If there was a previous bidder, verify they get refunded using indexed lookup currentHighestBidder match case Option.Some(previousBidder) => // refundOutputIdx >= 0 means there should be a refund output require( refundOutputIdx >= BigInt(0), "Refund output index required when previous bidder exists" ) val refundOutput = txInfo.outputs.at(refundOutputIdx) require( refundOutput.address === Address.fromPubKeyHash(previousBidder), "Refund output must go to previous bidder" ) require( refundOutput.value.getLovelace === currentHighestBid, "Previous bidder must receive exactly their bid amount" ) case Option.None => // No previous bidder, no refund needed () /** Handle end action using indexed UTxO pattern for O(1) lookups */ private inline def handleEnd( txInfo: TxInfo, scriptHash: ValidatorHash, datum: Datum, sellerOutputIdx: BigInt, winnerOutputIdx: BigInt ): Unit = val Datum(seller, currentHighestBidder, currentHighestBid, auctionEndTime, itemId) = datum // 1. Time validation: must be after auction end require( txInfo.validRange.isEntirelyAfter(auctionEndTime), "Auction can only end after the end time" ) // 2. Verify only one auction NFT is being spent from this script (prevents double satisfaction) // This ensures each End action corresponds to exactly one auction val scriptAddress = Address.fromScriptHash(scriptHash) val totalAuctionNftsSpent = txInfo.inputs.foldLeft(BigInt(0)) { (count, input) => if input.resolved.address === scriptAddress then count + input.resolved.value.tokens(scriptHash).values.foldLeft(BigInt(0))(_ + _) else count } require( totalAuctionNftsSpent === BigInt(1), "Only one auction can be ended per transaction (prevents double satisfaction)" ) currentHighestBidder match case Option.Some(winner) => // 3. Winner cannot be the seller (defense in depth - also checked in handleBid) require( !(winner === seller), "Seller cannot be the winner" ) // 3. Winner must receive the NFT (the auctioned item) - use indexed lookup require( winnerOutputIdx >= BigInt(0), "Winner output index required when there is a winner" ) val winnerOutput = txInfo.outputs.at(winnerOutputIdx) require( winnerOutput.address === Address.fromPubKeyHash(winner), "Winner output must go to the winner" ) // Verify winner receives exactly this auction's NFT (prevents double satisfaction) // If multiple auctions shared this output, it would have multiple NFTs val totalNftsInWinnerOutput = winnerOutput.value.tokens(scriptHash).values.foldLeft(BigInt(0))(_ + _) require( totalNftsInWinnerOutput === BigInt(1), "Winner output must have exactly one auction NFT (no bundling)" ) require( winnerOutput.value.quantityOf(scriptHash, itemId) === BigInt(1), "Winner must receive this auction's NFT" ) // 3. Seller must receive the highest bid amount - use indexed lookup val sellerOutput = txInfo.outputs.at(sellerOutputIdx) require( sellerOutput.address === Address.fromPubKeyHash(seller), "Seller output must go to the seller" ) require( sellerOutput.value.getLovelace >= currentHighestBid, "Seller must receive at least the highest bid amount" ) // Tag the seller payout with this auction's unique id (its scriptHash). Each auction // is one-shot-parameterized to a distinct scriptHash, so the per-hash NFT-input count // above cannot see a sibling auction at a *different* script address. Without a tag, // two same-seller auctions ended in one tx could share a single seller output (each // check is only `>=` its own bid), letting an attacker pay the seller once and pocket // the rest. Requiring the seller output to carry this auction's scriptHash forces a // distinct seller output per auction, closing the cross-instance double satisfaction. val sellerOutputDatum = sellerOutput.datum match case OutputDatum.OutputDatum(d) => d case _ => fail("Seller output must carry this auction's id datum") require( sellerOutputDatum == scriptHash.toData, "Seller output must be tagged with this auction's id" ) case Option.None => // No bidders - seller can reclaim the item // Seller must sign to end without bids require( txInfo.isSignedBy(seller), "Seller must sign to end auction without bids" ) // NFT goes back to seller - use indexed lookup val sellerOutput = txInfo.outputs.at(sellerOutputIdx) require( sellerOutput.address === Address.fromPubKeyHash(seller), "Seller output must go to the seller" ) // Verify seller receives exactly this auction's NFT (prevents double satisfaction) val totalNftsInSellerOutput = sellerOutput.value.tokens(scriptHash).values.foldLeft(BigInt(0))(_ + _) require( totalNftsInSellerOutput === BigInt(1), "Seller output must have exactly one auction NFT (no bundling)" ) require( sellerOutput.value.quantityOf(scriptHash, itemId) === BigInt(1), "Seller must receive back this auction's NFT" ) inline override def mint( oneShotData: Data, redeemer: Data, policyId: PolicyId, txInfo: TxInfo ): Unit = val oneShot = oneShotData.to[TxOutRef] val action = redeemer.to[Action] action match case Action.Start(itemId, seller, startingBid, auctionEndTime) => handleMint(oneShot, policyId, txInfo, itemId, seller, startingBid, auctionEndTime) case _ => // For End action - burning is allowed handleBurn(policyId, txInfo) private inline def handleMint( oneShot: TxOutRef, policyId: PolicyId, txInfo: TxInfo, itemId: ByteString, seller: PubKeyHash, startingBid: BigInt, auctionEndTime: PosixTime ): Unit = // 1. Verify the one-shot UTxO is being spent (ensures unique policyId per auction) require( txInfo.inputs.exists(_.outRef === oneShot), "Must spend the one-shot UTxO to create auction" ) // 2. Seller must sign the transaction require( txInfo.isSignedBy(seller), "Seller must sign to start auction" ) // 3. Validate ALL tokens minted under this policy (prevents Other Token Name Attack) val mintedTokens = txInfo.mint.tokens(policyId) require( mintedTokens.size === BigInt(1), "Only one token name allowed per auction start" ) val (mintedTokenName, mintedQuantity) = mintedTokens.toList.head require( mintedTokenName === itemId && mintedQuantity === BigInt(1), "Must mint exactly one auction NFT with the specified itemId" ) // 4. The auction end time must be in the future require( txInfo.validRange.isEntirelyBefore(auctionEndTime), "Auction end time must be in the future" ) // 5. Starting bid must be positive require( startingBid > BigInt(0), "Starting bid must be positive" ) // 6. Find the output going to the script address val auctionOutput = txInfo.outputs.filter { out => out.address === Address.fromScriptHash(policyId) }.match case List.Cons(out, List.Nil) => out case _ => fail("There must be exactly one output to the auction script") // 7. Verify the output contains the minted NFT require( auctionOutput.value.quantityOf(policyId, itemId) === BigInt(1), "Auction output must contain the minted NFT" ) // 8. Verify the datum is correct val expectedDatum = Datum( seller = seller, highestBidder = Option.None, highestBid = startingBid, auctionEndTime = auctionEndTime, itemId = itemId ) auctionOutput.datum match case OutputDatum.OutputDatum(datumData) => require( datumData.to[Datum] === expectedDatum, "Initial auction datum must be correct" ) case _ => fail("Auction output must have inline datum") private inline def handleBurn( policyId: PolicyId, txInfo: TxInfo ): Unit = // For burning, verify all tokens of this policy are burned (negative quantity) val mintedTokens = txInfo.mint.tokens(policyId) require( mintedTokens.forall { case (_, amount) => amount < BigInt(0) }, "Only burning is allowed (all amounts must be negative)" ) } /** Blueprint and compiled script for the auction contract. * * Apply a one-shot `TxOutRef` (as Data) to `compiled` to get a unique auction instance. */ object AuctionContract extends Contract { private given Options = Options.release /** Compiled parameterized auction validator. Apply a TxOutRef (as Data) to get a unique auction * instance. */ lazy val compiled: PlutusV3[Data => Data => Unit] = PlutusV3.compile(AuctionValidator.validate) lazy val blueprint = Blueprint.plutusV3[TxOutRef, Datum, Action]( title = "Auction", description = "First-price single-item auction parameterized by a one-shot UTxO that makes each " + "instance's policy id unique. Bidders raise the standing bid before the deadline; " + "the highest bidder claims the item and the seller is paid when the auction ends.", version = "1.0.0", license = Some("Apache-2.0"), // AuctionValidator is a DataParameterizedValidator, so the one-shot TxOutRef parameter is // applied as Data on the UPLC level and `compiled` is typed `Data => Data => Unit`. The cast // only re-labels the phantom type so the parameter schema is derived as TxOutRef; the // compiled program (and thus its hash and CBOR) is unchanged. compiled = compiled.asInstanceOf[PlutusV3[TxOutRef => Data => Unit]] ) } /** Factory for creating auction instances with unique policyIds. * * Each auction is parameterized by a one-shot UTxO reference, ensuring globally unique policyIds. * This prevents UTXO discovery confusion attacks where multiple auctions could share the same * itemId. * * @param provider * Node provider for queries and submission * @param withErrorTraces * If true, include error traces for debugging (default: false for production) */ class AuctionFactory(provider: BlockchainProvider, withErrorTraces: Boolean = false) { private val baseContract = if withErrorTraces then AuctionContract.compiled.withErrorTraces else AuctionContract.compiled /** Creates a new auction instance parameterized by the given one-shot UTxO. * * @param oneShot * UTxO reference that will be spent to create the auction (ensures unique policyId) * @return * AuctionInstance with unique policyId and script address */ def createInstance(oneShot: TxOutRef): AuctionInstance = { val appliedContract = baseContract.apply(Data.toData(oneShot)) AuctionInstance( provider = provider, oneShot = oneShot, compiledContract = appliedContract ) } } /** A specific auction instance with a unique policyId derived from the one-shot UTxO. * * @param provider * Node provider for queries and submission * @param oneShot * The UTxO reference that parameterizes this auction (must be spent on creation) * @param compiledContract * The compiled contract with oneShot applied */ class AuctionInstance( provider: BlockchainProvider, val oneShot: TxOutRef, compiledContract: PlutusV3[Data => Unit] ) { private def env: CardanoInfo = provider.cardanoInfo private val scriptHash: scalus.cardano.ledger.ScriptHash = compiledContract.script.scriptHash def scriptAddress: CardanoAddress = compiledContract.address(env.network) /** Extract PubKeyHash from a ShelleyAddress */ private def extractPkh(address: ShelleyAddress): PubKeyHash = address.payment match case ShelleyPaymentPart.Key(hash) => PubKeyHash(hash) case _ => throw IllegalArgumentException("Address must have key payment credential") /** Create a ShelleyAddress from a PubKeyHash */ private def addressFromPkh(pkh: PubKeyHash): ShelleyAddress = ShelleyAddress( env.network, ShelleyPaymentPart.Key(AddrKeyHash.fromByteString(pkh.hash)), ShelleyDelegationPart.Null ) /** Starts an auction for the given itemId by minting an NFT representing the item. * * The oneShot UTxO (used to parameterize this auction instance) must be owned by the seller * and will be spent in this transaction to ensure the auction can only be created once. * * @param sellerAddress * The seller's address for receiving funds and signing * @param oneShotUtxo * The UTxO to spend as one-shot (must match the oneShot used to create this instance) * @param itemId * Unique identifier for the auctioned item (becomes token name) * @param startingBid * Minimum bid amount in lovelace * @param auctionEndTime * POSIX timestamp when the auction ends * @param initialValue * Initial ADA locked with the auction (for min UTxO requirements) * @param signer * Transaction signer with seller's keys * @return * The submitted transaction */ def startAuction( sellerAddress: ShelleyAddress, oneShotUtxo: Utxo, itemId: ByteString, startingBid: Long, auctionEndTime: PosixTime, initialValue: Coin, signer: TransactionSigner ): Future[Transaction] = given scala.concurrent.ExecutionContext = provider.executionContext // Verify the provided UTxO matches the oneShot parameter require( oneShotUtxo.input.transactionId == oneShot.id.hash && oneShotUtxo.input.index == oneShot.idx.toInt, s"Provided UTxO ${oneShotUtxo.input} does not match oneShot ${oneShot}" ) val sellerPkh = extractPkh(sellerAddress) for _ <- Future.unit datum = Datum( seller = sellerPkh, highestBidder = Option.None, highestBid = BigInt(startingBid), auctionEndTime = auctionEndTime, itemId = itemId ) redeemer = Action.Start( itemId = itemId, seller = sellerPkh, startingBid = BigInt(startingBid), auctionEndTime = auctionEndTime ) nftAsset = AssetName(itemId) mintedValue = LedgerValue.asset(scriptHash, nftAsset, 1L) sellerAddrKeyHash = AddrKeyHash.fromByteString(sellerPkh.hash) // Spend the oneShot UTxO and mint the auction NFT tx <- TxBuilder(env) .spend(oneShotUtxo) // Spend the one-shot UTxO (pubkey-protected) .mint(compiledContract, Map(nftAsset -> 1L), redeemer) .requireSignature(sellerAddrKeyHash) .payTo(scriptAddress, LedgerValue(initialValue) + mintedValue, datum) .validTo(Instant.ofEpochMilli(auctionEndTime.toLong - 1000)) .complete(provider, sellerAddress) .map(_.sign(signer).transaction) _ <- provider.submit(tx).map { case Right(_) => () case Left(error) => throw RuntimeException(s"Failed to submit: $error") } yield tx /** Places a bid on this auction. * * @param bidderAddress * The bidder's address * @param bidAmount * The bid amount in lovelace * @param itemId * The auction item identifier (token name of the auction NFT) * @param signer * Transaction signer with bidder's keys * @return * The submitted transaction */ def bid( bidderAddress: ShelleyAddress, bidAmount: Long, itemId: ByteString, signer: TransactionSigner ): Future[Transaction] = given scala.concurrent.ExecutionContext = provider.executionContext val bidderPkh = extractPkh(bidderAddress) for auctionUtxo <- findAuctionUtxo(itemId).map( _.getOrElse(throw RuntimeException(s"No active auction found at $scriptAddress")) ) currentDatum = auctionUtxo.output.inlineDatum .getOrElse(throw IllegalStateException("Auction UTxO must have inline datum")) .to[Datum] newDatum = currentDatum.copy( highestBidder = Option.Some(bidderPkh), highestBid = BigInt(bidAmount) ) nftAsset = AssetName(currentDatum.itemId) nftValue = LedgerValue.asset(scriptHash, nftAsset, 1L) newAuctionValue = LedgerValue.lovelace(bidAmount) + nftValue // Calculate previous bidder address for refund output index computation prevBidderAddr: scala.Option[ShelleyAddress] = currentDatum.highestBidder match case Option.Some(prevBidder) => scala.Some(addressFromPkh(prevBidder)) case Option.None => scala.None // Build transaction with delayed redeemer that computes indices // The redeemerBuilder receives the complete transaction and computes indices builder = TxBuilder(env) .spend( auctionUtxo, redeemerBuilder = (tx: Transaction) => { // Compute input index - find our auction input val inputIdx = tx.body.value.inputs.toSeq.indexOf(auctionUtxo.input) // Compute output index - find the continuing auction output val outputIdx = tx.body.value.outputs.indexWhere { sized => sized.value.address == scriptAddress } // Compute refund output index if there was a previous bidder val refundOutputIdx = prevBidderAddr match case scala.Some(addr) => tx.body.value.outputs.indexWhere { sized => sized.value.address == addr } case scala.None => -1 Action .Bid( BigInt(bidAmount), bidderPkh, BigInt(inputIdx), BigInt(outputIdx), BigInt(refundOutputIdx) ) .toData }, compiledContract ) .requireSignature(AddrKeyHash.fromByteString(bidderPkh.hash)) .payTo(scriptAddress, newAuctionValue, newDatum) .validTo(Instant.ofEpochMilli(currentDatum.auctionEndTime.toLong - 1000)) builderWithRefund = prevBidderAddr match case scala.Some(addr) => builder.payTo(addr, LedgerValue.lovelace(currentDatum.highestBid.toLong)) case scala.None => builder tx <- builderWithRefund .complete(provider, bidderAddress) .map(_.sign(signer).transaction) _ <- provider.submit(tx).map { case Right(_) => () case Left(error) => throw RuntimeException(s"Failed to submit: $error") } yield tx /** Ends this auction. * * Transfers the NFT to the winner and funds to the seller. If no bids were placed, the seller * reclaims the NFT (seller must sign). * * @param sponsorAddress * Address to pay transaction fees from * @param itemId * The auction item identifier (token name of the auction NFT) * @param signer * Transaction signer (seller must sign if no bids) * @return * The submitted transaction */ def endAuction( sponsorAddress: ShelleyAddress, itemId: ByteString, signer: TransactionSigner ): Future[Transaction] = given scala.concurrent.ExecutionContext = provider.executionContext for auctionUtxo <- findAuctionUtxo(itemId).map( _.getOrElse(throw RuntimeException(s"No active auction found at $scriptAddress")) ) currentDatum = auctionUtxo.output.inlineDatum .getOrElse(throw IllegalStateException("Auction UTxO must have inline datum")) .to[Datum] nftAsset = AssetName(currentDatum.itemId) nftValue = LedgerValue.asset(scriptHash, nftAsset, 1L) sellerAddr = addressFromPkh(currentDatum.seller) sellerAddrKeyHash = AddrKeyHash.fromByteString(currentDatum.seller.hash) // Determine required signers based on whether there are bids // If no bids, seller must sign to reclaim NFT spendRequiredSigners = currentDatum.highestBidder match case Option.Some(_) => Set.empty[AddrKeyHash] case Option.None => Set(sellerAddrKeyHash) // Calculate winner address for output index computation winnerAddr: scala.Option[ShelleyAddress] = currentDatum.highestBidder match case Option.Some(winner) => scala.Some(addressFromPkh(winner)) case Option.None => scala.None // Build transaction with delayed redeemer that computes indices builder = TxBuilder(env) .spend( auctionUtxo, redeemerBuilder = (tx: Transaction) => { // Compute input index - find our auction input val inputIdx = tx.body.value.inputs.toSeq.indexOf(auctionUtxo.input) // Compute seller output index val sellerOutputIdx = tx.body.value.outputs.indexWhere { sized => sized.value.address == sellerAddr } // Compute winner output index if there is a winner val winnerOutputIdx = winnerAddr match case scala.Some(addr) => tx.body.value.outputs.indexWhere { sized => sized.value.address == addr } case scala.None => -1 Action .End(BigInt(inputIdx), BigInt(sellerOutputIdx), BigInt(winnerOutputIdx)) .toData }, compiledContract ) .requireSignatures(spendRequiredSigners) .validFrom(Instant.ofEpochMilli(currentDatum.auctionEndTime.toLong + 1000)) builderWithOutputs = winnerAddr match case scala.Some(addr) => // Winner gets the NFT (auctioned item), seller gets the bid amount. The seller // output is tagged with this auction's scriptHash so it cannot be shared with a // sibling auction (prevents cross-instance double satisfaction). builder .payTo(addr, LedgerValue.lovelace(2_000_000L) + nftValue) .payTo( sellerAddr, LedgerValue.lovelace(currentDatum.highestBid.toLong), scriptHash: ByteString ) case scala.None => // No bids - seller reclaims the NFT (auctioned item) builder.payTo(sellerAddr, LedgerValue.lovelace(2_000_000L) + nftValue) tx <- builderWithOutputs .complete(provider, sponsorAddress) .map(_.sign(signer).transaction) _ <- provider.submit(tx).map { case Right(_) => () case Left(error) => throw RuntimeException(s"Failed to submit: $error") } yield tx /** Finds the auction UTxO at this auction's script address by filtering for the auction NFT. * * Filters by both the script address and the auction NFT asset to avoid picking up spam UTxOs * that anyone could send to the script address. On Blockfrost, this uses the optimized * `/addresses/{addr}/utxos/{asset}` endpoint. * * @param itemId * The auction item identifier (token name of the auction NFT) * @return * The auction UTxO if found */ def findAuctionUtxo(itemId: ByteString): Future[scala.Option[Utxo]] = given scala.concurrent.ExecutionContext = provider.executionContext val nftAsset = AssetName(itemId) provider .queryUtxos { u => u.output.address == scriptAddress && u.output.value.hasAsset(scriptHash, nftAsset) } .limit(1) .execute() .map { case Right(utxos) => utxos.headOption.map { case (input, output) => Utxo(input, output) } case Left(_) => scala.None } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/auction/UnfixedAuction.scala ```scala package scalus.examples.auction import scalus.compiler.Compile import scalus.uplc.builtin.{ByteString, Data} import scalus.cardano.onchain.plutus.v1.{Address, Credential, PubKeyHash} import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.{Datum as _, *} import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.v3.Validator /** VULNERABLE Auction Validator - DO NOT USE IN PRODUCTION * * This validator demonstrates the Double Satisfaction vulnerability (V005). When multiple auctions * with the same seller end in a single transaction, a single seller output can satisfy validation * for all of them. * * Attack scenario: * 1. Bob wins AuctionA (seller=Alice, highestBid=10 ADA) and AuctionB (seller=Alice, * highestBid=10 ADA) * 2. Bob ends both auctions in one transaction * 3. Bob creates only ONE output of 10 ADA to Alice (instead of 20 ADA) * 4. Both validators pass because they share the same sellerOutputIdx * 5. Alice loses 10 ADA * * The fix is to require the auction NFT to be burned, creating a 1:1 mapping between each auction * End and its unique NFT burn operation. */ @Compile object UnfixedAuctionValidator extends Validator { inline override def spend( @annotation.unused datum: Option[Data], redeemer: Data, txInfo: TxInfo, txOutRef: TxOutRef ): Unit = redeemer.to[Action] match case Action.Bid(bidAmount, bidder, inputIdx, outputIdx, refundOutputIdx) => val input = txInfo.inputs.at(inputIdx) require(input.outRef === txOutRef, "Input index does not match txOutRef") val (scriptHash, inputValue, currentDatum) = input.resolved match case TxOut( Address(Credential.ScriptCredential(sh), _), value, OutputDatum.OutputDatum(inlineDatum), _ ) => (sh, value, inlineDatum.to[Datum]) case _ => fail("Auction input must have script credential and inline datum") handleBid( txInfo, scriptHash, currentDatum, bidAmount, bidder, outputIdx, refundOutputIdx ) case Action.End(inputIdx, sellerOutputIdx, winnerOutputIdx) => val input = txInfo.inputs.at(inputIdx) require(input.outRef === txOutRef, "Input index does not match txOutRef") val (scriptHash, inputValue, currentDatum) = input.resolved match case TxOut( Address(Credential.ScriptCredential(sh), _), value, OutputDatum.OutputDatum(inlineDatum), _ ) => (sh, value, inlineDatum.to[Datum]) case _ => fail("Auction input must have script credential and inline datum") handleEnd(txInfo, scriptHash, currentDatum, sellerOutputIdx, winnerOutputIdx) case Action.Start(_, _, _, _) => fail("Start action is only valid for minting") private inline def handleBid( txInfo: TxInfo, scriptHash: ValidatorHash, datum: Datum, bidAmount: BigInt, bidder: PubKeyHash, outputIdx: BigInt, refundOutputIdx: BigInt ): Unit = val Datum(seller, currentHighestBidder, currentHighestBid, auctionEndTime, itemId) = datum require( txInfo.validRange.isEntirelyBefore(auctionEndTime), "Bid must be placed before auction ends" ) require( txInfo.isSignedBy(bidder), "Bidder must sign the transaction" ) require( !(bidder === seller), "Seller cannot bid on their own auction" ) require( bidAmount > currentHighestBid, "Bid must be higher than current highest bid" ) val continuingOutput = txInfo.outputs.at(outputIdx) require( continuingOutput.address === Address.fromScriptHash(scriptHash), "Continuing output must go to auction script address" ) val newDatum = continuingOutput.datum match case OutputDatum.OutputDatum(newDatumData) => newDatumData.to[Datum] case _ => fail("Continuing auction output must have inline datum") val expectedNewDatum = Datum( seller = seller, highestBidder = Option.Some(bidder), highestBid = bidAmount, auctionEndTime = auctionEndTime, itemId = itemId ) require( newDatum === expectedNewDatum, "New datum must reflect the new bid" ) require( continuingOutput.value.quantityOf(scriptHash, itemId) === BigInt(1), "Auction NFT must be preserved" ) require( continuingOutput.value.getLovelace >= bidAmount, "Continuing output must contain at least the bid amount" ) currentHighestBidder match case Option.Some(previousBidder) => require( refundOutputIdx >= BigInt(0), "Refund output index required when previous bidder exists" ) val refundOutput = txInfo.outputs.at(refundOutputIdx) require( refundOutput.address === Address.fromPubKeyHash(previousBidder), "Refund output must go to previous bidder" ) require( refundOutput.value.getLovelace === currentHighestBid, "Previous bidder must receive exactly their bid amount" ) case Option.None => () /** VULNERABLE: This function has a Double Satisfaction vulnerability. * * The problem is at the seller output validation: * - It uses `>=` instead of `===` for the payment amount * - Multiple auction Ends can share the same sellerOutputIdx * - No unique linking between this auction and its output * * FIX: Add `require(txInfo.mint.quantityOf(scriptHash, itemId) === BigInt(-1))` to ensure each * auction's NFT is burned, creating 1:1 input-burn mapping. */ private inline def handleEnd( txInfo: TxInfo, scriptHash: ValidatorHash, datum: Datum, sellerOutputIdx: BigInt, winnerOutputIdx: BigInt ): Unit = val Datum(seller, currentHighestBidder, currentHighestBid, auctionEndTime, itemId) = datum require( txInfo.validRange.isEntirelyAfter(auctionEndTime), "Auction can only end after the end time" ) // VULNERABILITY: No check that this auction's NFT is being burned! // This allows multiple auctions to share the same seller output. currentHighestBidder match case Option.Some(winner) => require( !(winner === seller), "Seller cannot be the winner" ) require( winnerOutputIdx >= BigInt(0), "Winner output index required when there is a winner" ) val winnerOutput = txInfo.outputs.at(winnerOutputIdx) require( winnerOutput.address === Address.fromPubKeyHash(winner), "Winner output must go to the winner" ) require( winnerOutput.value.quantityOf(scriptHash, itemId) === BigInt(1), "Winner must receive the auction NFT" ) // VULNERABLE: Uses >= and no unique linking to this specific auction val sellerOutput = txInfo.outputs.at(sellerOutputIdx) require( sellerOutput.address === Address.fromPubKeyHash(seller), "Seller output must go to the seller" ) require( sellerOutput.value.getLovelace >= currentHighestBid, "Seller must receive at least the highest bid amount" ) case Option.None => require( txInfo.isSignedBy(seller), "Seller must sign to end auction without bids" ) val sellerOutput = txInfo.outputs.at(sellerOutputIdx) require( sellerOutput.address === Address.fromPubKeyHash(seller), "Seller output must go to the seller" ) require( sellerOutput.value.quantityOf(scriptHash, itemId) === BigInt(1), "Seller must receive back the auction NFT" ) inline override def mint( redeemer: Data, policyId: PolicyId, txInfo: TxInfo ): Unit = val action = redeemer.to[Action] action match case Action.Start(itemId, seller, startingBid, auctionEndTime) => handleMint(policyId, txInfo, itemId, seller, startingBid, auctionEndTime) case _ => handleBurn(policyId, txInfo) private inline def handleMint( policyId: PolicyId, txInfo: TxInfo, itemId: ByteString, seller: PubKeyHash, startingBid: BigInt, auctionEndTime: PosixTime ): Unit = require( txInfo.isSignedBy(seller), "Seller must sign to start auction" ) val mintedTokens = txInfo.mint.tokens(policyId) require( mintedTokens.size === BigInt(1), "Only one token name allowed per auction start" ) val (mintedTokenName, mintedQuantity) = mintedTokens.toList.head require( mintedTokenName === itemId && mintedQuantity === BigInt(1), "Must mint exactly one auction NFT with the specified itemId" ) require( txInfo.validRange.isEntirelyBefore(auctionEndTime), "Auction end time must be in the future" ) require( startingBid > BigInt(0), "Starting bid must be positive" ) val auctionOutput = txInfo.outputs.filter { out => out.address === Address.fromScriptHash(policyId) }.match case List.Cons(out, List.Nil) => out case _ => fail("There must be exactly one output to the auction script") require( auctionOutput.value.quantityOf(policyId, itemId) === BigInt(1), "Auction output must contain the minted NFT" ) val expectedDatum = Datum( seller = seller, highestBidder = Option.None, highestBid = startingBid, auctionEndTime = auctionEndTime, itemId = itemId ) auctionOutput.datum match case OutputDatum.OutputDatum(datumData) => require( datumData.to[Datum] === expectedDatum, "Initial auction datum must be correct" ) case _ => fail("Auction output must have inline datum") private inline def handleBurn( policyId: PolicyId, txInfo: TxInfo ): Unit = val mintedTokens = txInfo.mint.tokens(policyId) require( mintedTokens.forall { case (_, amount) => amount < BigInt(0) }, "Only burning is allowed (all amounts must be negative)" ) } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/auction/AuctionTestKitTest.scala ```scala package scalus.examples.auction import cps.* import org.scalacheck.Prop import org.scalatest.funsuite.AnyFunSuite import scalus.cardano.address.{Address as CardanoAddress, Network, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart} import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.PlutusScriptsTransactionMutator import scalus.cardano.node.{BlockchainReader, Emulator} import scalus.cardano.onchain.plutus.v1.{PosixTime, PubKeyHash} import scalus.cardano.onchain.plutus.v3.{TxId, TxOutRef} import scalus.cardano.txbuilder.TxBuilder import scalus.testing.* import scalus.testing.kit.Party.{Alice, Bob, Charles} import scalus.testing.kit.TestUtil.genesisHash import scalus.uplc.builtin.ByteString.* import scalus.uplc.builtin.Data import java.time.Instant import scala.concurrent.duration.Duration import scala.concurrent.{Await, ExecutionContext, Future} class AuctionTestKitTest extends AnyFunSuite { private given ExecutionContext = ExecutionContext.global import AuctionTestKitTest.* // ========================================================================= // ContractScalaCheckCommands Tests // ========================================================================= test("ContractScalaCheckCommands: bid step") { resetCounters() val t0 = System.currentTimeMillis() val emulator = createEmulator() val sellerUtxos = Await.result(emulator.findUtxos(sellerAddress), Duration.Inf).toOption.get val oneShotUtxo = Utxo(sellerUtxos.head) val (_, script, scriptHash, scriptAddress) = startAuction(emulator, oneShotUtxo) val bidStep = AuctionBidStep( script, scriptHash, scriptAddress, bidder1Address, bidder1Party.signer, bidAmount = 3_000_000L ) val commands = ContractScalaCheckCommands(emulator, bidStep) { (_, state) => Future { val sellerPkh = extractPkh(sellerAddress) Prop(state.datum.seller == sellerPkh) && Prop(state.datum.auctionEndTime == getAuctionEndTime(emulator.cardanoInfo)) } } val result = org.scalacheck.Test.check( org.scalacheck.Test.Parameters.default .withMinSuccessfulTests(3) .withMaxDiscardRatio(20), commands.property() ) System.err.println( s" tests: ${result.succeeded}, variations: ${txVariationCount.get()}, time: ${System.currentTimeMillis() - t0}ms" ) assert(result.passed, s"Bid step property test failed: $result") } test("ContractScalaCheckCommands: end step after bid") { resetCounters() val t0 = System.currentTimeMillis() val emulator = createEmulator() val sellerUtxos = Await.result(emulator.findUtxos(sellerAddress), Duration.Inf).toOption.get val oneShotUtxo = Utxo(sellerUtxos.head) val (instance, script, scriptHash, scriptAddress) = startAuction(emulator, oneShotUtxo) Await.result( instance.bid( bidderAddress = bidder1Address, bidAmount = 3_000_000L, itemId = itemId, signer = bidder1Party.signer ), Duration.Inf ) emulator.setSlot(auctionEndSlot + 20) val endStep = AuctionEndStep( script, scriptHash, scriptAddress, sellerAddress, sellerParty.signer, Network.Mainnet ) val commands = ContractScalaCheckCommands(emulator, endStep)() val result = org.scalacheck.Test.check( org.scalacheck.Test.Parameters.default .withMinSuccessfulTests(3) .withMaxDiscardRatio(20), commands.property() ) System.err.println( s" tests: ${result.succeeded}, variations: ${txVariationCount.get()}, time: ${System.currentTimeMillis() - t0}ms" ) assert(result.passed, s"End step property test failed: $result") } // ========================================================================= // Multiple Auctions Test // ========================================================================= test("ContractScalaCheckCommands: bid step with two auctions in state") { resetCounters() val t0 = System.currentTimeMillis() val emulator = createEmulatorForTwoAuctions() val sellerUtxos = Await.result(emulator.findUtxos(sellerAddress), Duration.Inf).toOption.get val sortedUtxos = sellerUtxos.toSeq.sortBy(_._1.index) val oneShotUtxo1 = Utxo(sortedUtxos(0)) val (_, script1, scriptHash1, scriptAddress1) = startAuction(emulator, oneShotUtxo1, utf8"auction-item-001") val oneShotUtxo2 = Utxo(sortedUtxos(1)) val (_, _, _, scriptAddress2) = startAuction(emulator, oneShotUtxo2, utf8"auction-item-002") assert( scriptAddress1 != scriptAddress2, "Two auctions should have different script addresses" ) val bidStep = AuctionBidStep( script1, scriptHash1, scriptAddress1, bidder1Address, bidder1Party.signer, bidAmount = 3_000_000L ) val commands = ContractScalaCheckCommands(emulator, bidStep) { (reader, state) => val sellerPkh = extractPkh(sellerAddress) val auction1Ok = Prop(state.datum.seller == sellerPkh) && Prop(state.datum.itemId == utf8"auction-item-001") reader .queryUtxos { u => u.output.address == scriptAddress2 } .limit(1) .execute() .map { case Right(utxos) if utxos.nonEmpty => val (_, output) = utxos.head val datum2 = output.inlineDatum.get.to[Datum] auction1Ok && Prop(datum2.itemId == utf8"auction-item-002") && Prop(datum2.seller == sellerPkh) case _ => Prop.falsified } } val result = org.scalacheck.Test.check( org.scalacheck.Test.Parameters.default .withMinSuccessfulTests(3) .withMaxDiscardRatio(20), commands.property() ) System.err.println( s" tests: ${result.succeeded}, variations: ${txVariationCount.get()}, time: ${System.currentTimeMillis() - t0}ms" ) assert(result.passed, s"Two-auction bid step property test failed: $result") } // ========================================================================= // ScenarioExplorer Tests // ========================================================================= test("ScenarioExplorer: multi-step auction lifecycle") { resetCounters() val t0 = System.currentTimeMillis() val emulator = createEmulator() val sellerUtxos = Await.result(emulator.findUtxos(sellerAddress), Duration.Inf).toOption.get val oneShotUtxo = Utxo(sellerUtxos.head) val (_, script, scriptHash, scriptAddress) = startAuction(emulator, oneShotUtxo) val auctionEnd = getAuctionEndTime(emulator.cardanoInfo) val scenario = ScenarioExplorer.explore(maxDepth = 3) { reader => async[Scenario] { val currentSlot = reader.currentSlot.await val currentTime = reader.cardanoInfo.slotConfig.slotToTime(currentSlot) if currentTime < auctionEnd.toLong then explorerBidStep(reader, script, scriptHash, scriptAddress, auctionEnd).await else explorerEndStep(reader, script, scriptHash, scriptAddress).await } } val results = Await.result(Scenario.runAll(emulator)(scenario), Duration.Inf) System.err.println( s" paths: ${results.size}, txBuilds: ${txBuildCount.get()}, time: ${System.currentTimeMillis() - t0}ms" ) val violations = results.flatMap(_._2) assert(violations.isEmpty, s"Found violations: ${violations.mkString("\n")}") } test("ScenarioExplorer: two auctions lifecycle") { resetCounters() val t0 = System.currentTimeMillis() val emulator = createEmulatorForTwoAuctions() val sellerUtxos = Await.result(emulator.findUtxos(sellerAddress), Duration.Inf).toOption.get val sortedUtxos = sellerUtxos.toSeq.sortBy(_._1.index) val oneShotUtxo1 = Utxo(sortedUtxos(0)) val (_, script1, scriptHash1, scriptAddress1) = startAuction(emulator, oneShotUtxo1, utf8"item-1") val oneShotUtxo2 = Utxo(sortedUtxos(1)) val (_, script2, scriptHash2, scriptAddress2) = startAuction(emulator, oneShotUtxo2, utf8"item-2") val auctionEnd = getAuctionEndTime(emulator.cardanoInfo) val scenario = ScenarioExplorer.explore(maxDepth = 2) { reader => async[Scenario] { val currentSlot = reader.currentSlot.await val currentTime = reader.cardanoInfo.slotConfig.slotToTime(currentSlot) if currentTime < auctionEnd.toLong then explorerTwoAuctionsBidStep( reader, script1, scriptHash1, scriptAddress1, script2, scriptHash2, scriptAddress2, auctionEnd ).await else () } } val results = Await.result(Scenario.runAll(emulator)(scenario), Duration.Inf) System.err.println( s" paths: ${results.size}, txBuilds: ${txBuildCount.get()}, time: ${System.currentTimeMillis() - t0}ms" ) val violations = results.flatMap(_._2) assert( violations.isEmpty, s"Found violations in two-auction test: ${violations.mkString("\n")}" ) assert(results.size > 1, s"Should explore multiple paths, got ${results.size}") } // ========================================================================= // Vulnerability Detection via Framework // ========================================================================= test("ScalaCheck detects double satisfaction vulnerability in UnfixedAuction") { // This test uses ContractScalaCheckCommands with a TwoAuctionEndStep that // includes a double satisfaction attack variation. // // The vulnerability: When two auctions from the same seller end in one tx, // a single seller payment can satisfy validation for BOTH auctions. // // Expected behavior: // - UnfixedAuction: attack variation SUCCEEDS (vulnerability!) // - Fixed Auction: attack variation FAILS (correctly rejected) import scalus.compiler.Options import scalus.uplc.PlutusV3 given Options = Options.release.copy(generateErrorTraces = true) resetCounters() val t0 = System.currentTimeMillis() // Compile the vulnerable contract val vulnerableContract = PlutusV3.compile(UnfixedAuctionValidator.validate) val script = vulnerableContract.script val scriptHash = script.scriptHash val scriptAddress = vulnerableContract.address(Network.Mainnet) val bidAmount = 5_000_000L val itemId1 = utf8"item-vuln-1" val itemId2 = utf8"item-vuln-2" // Create two won auctions (both from same seller, both won by same bidder) val wonDatum1 = Datum( seller = extractPkh(sellerAddress), highestBidder = scalus.cardano.onchain.plutus.prelude.Option.Some(extractPkh(bidder1Address)), highestBid = BigInt(bidAmount), auctionEndTime = getAuctionEndTime(CardanoInfo.mainnet), itemId = itemId1 ) val wonDatum2 = Datum( seller = extractPkh(sellerAddress), highestBidder = scalus.cardano.onchain.plutus.prelude.Option.Some(extractPkh(bidder1Address)), highestBid = BigInt(bidAmount), auctionEndTime = getAuctionEndTime(CardanoInfo.mainnet), itemId = itemId2 ) val auctionValue1 = Value.lovelace(bidAmount + 2_000_000L) + Value.asset(scriptHash, AssetName(itemId1), 1L) val auctionValue2 = Value.lovelace(bidAmount + 2_000_000L) + Value.asset(scriptHash, AssetName(itemId2), 1L) // Create emulator with the two auction UTxOs ready to end val emulator = Emulator( initialUtxos = Map( Input(genesisHash, 10) -> TransactionOutput.Babbage( scriptAddress, auctionValue1, datumOption = Some(DatumOption.Inline(Data.toData(wonDatum1))) ), Input(genesisHash, 11) -> TransactionOutput.Babbage( scriptAddress, auctionValue2, datumOption = Some(DatumOption.Inline(Data.toData(wonDatum2))) ), Input(genesisHash, 20) -> TransactionOutput.Babbage( bidder1Address, Value.lovelace(100_000_000L) ) ), initialContext = scalus.cardano.ledger.rules.Context.testMainnet(slot = auctionEndSlot + 10), mutators = Set(PlutusScriptsTransactionMutator) ) val twoAuctionStep = new TwoAuctionEndStep( script, scriptHash, scriptAddress, bidder1Address, bidder1Party.signer, Network.Mainnet ) // Use ContractScalaCheckCommands to test variations // The invariant checks that seller receives correct total payment val commands = ContractScalaCheckCommands(emulator, twoAuctionStep) { (reader, state) => // After ending both auctions, check seller balance reader.findUtxos(sellerAddress).map { case Right(utxos) => val sellerTotal: Long = utxos.values.map(_.value.coin.value).sum val expectedTotal = state.datum1.highestBid.toLong + state.datum2.highestBid.toLong // This check will FAIL for the attack tx on vulnerable contract // because seller only gets paid once Prop(sellerTotal >= expectedTotal) :| s"Seller should receive $expectedTotal lovelace but got $sellerTotal" case Left(_) => Prop.passed // no seller utxos yet is ok } } val result = org.scalacheck.Test.check( org.scalacheck.Test.Parameters.default .withMinSuccessfulTests(5) .withMaxDiscardRatio(20), commands.property() ) // System.err.println( // s" tests: ${result.succeeded}, variations: ${txVariationCount.get()}, time: ${System.currentTimeMillis() - t0}ms" // ) // For the VULNERABLE contract, we expect the test to FAIL because // the attack variation succeeds but violates the invariant assert( !result.passed, "Checker should detect vulnerability: expected property failure but all passed" ) // System.err.println(s" VULNERABILITY DETECTED: ${result.status}") } test("ScenarioExplorer finds double satisfaction vulnerability in UnfixedAuction") { // This test explores different auction ending strategies. // The "end-batch" strategy ends all auctions in one tx with merged seller // payment (a natural optimization). For a vulnerable contract, this tx succeeds // even though the seller is underpaid — discovering the double satisfaction bug. import scalus.compiler.Options import scalus.uplc.PlutusV3 given Options = Options.release.copy(generateErrorTraces = true) resetCounters() val t0 = System.currentTimeMillis() val vulnerableContract = PlutusV3.compile(UnfixedAuctionValidator.validate) val script = vulnerableContract.script val scriptHash = script.scriptHash val scriptAddress = vulnerableContract.address(Network.Mainnet) val bidAmount = 5_000_000L val iid1 = utf8"item-vuln-1" val iid2 = utf8"item-vuln-2" val wonDatum1 = Datum( seller = extractPkh(sellerAddress), highestBidder = scalus.cardano.onchain.plutus.prelude.Option.Some(extractPkh(bidder1Address)), highestBid = BigInt(bidAmount), auctionEndTime = getAuctionEndTime(CardanoInfo.mainnet), itemId = iid1 ) val wonDatum2 = Datum( seller = extractPkh(sellerAddress), highestBidder = scalus.cardano.onchain.plutus.prelude.Option.Some(extractPkh(bidder1Address)), highestBid = BigInt(bidAmount), auctionEndTime = getAuctionEndTime(CardanoInfo.mainnet), itemId = iid2 ) val auctionValue1 = Value.lovelace(bidAmount + 2_000_000L) + Value.asset(scriptHash, AssetName(iid1), 1L) val auctionValue2 = Value.lovelace(bidAmount + 2_000_000L) + Value.asset(scriptHash, AssetName(iid2), 1L) val emulator = Emulator( initialUtxos = Map( Input(genesisHash, 10) -> TransactionOutput.Babbage( scriptAddress, auctionValue1, datumOption = Some(DatumOption.Inline(Data.toData(wonDatum1))) ), Input(genesisHash, 11) -> TransactionOutput.Babbage( scriptAddress, auctionValue2, datumOption = Some(DatumOption.Inline(Data.toData(wonDatum2))) ), Input(genesisHash, 20) -> TransactionOutput.Babbage( bidder1Address, Value.lovelace(100_000_000L) ), Input(genesisHash, 21) -> TransactionOutput.Babbage( sellerAddress, Value.lovelace(100_000_000L) ) ), initialContext = scalus.cardano.ledger.rules.Context.testMainnet(slot = auctionEndSlot + 10), mutators = Set(PlutusScriptsTransactionMutator) ) // Quick pre-check: verify the batch tx works with the emulator val preCheckReader = ImmutableEmulator.fromEmulator(emulator).asReader val preCheckTx = buildBatchEndTx( preCheckReader, Await .result( preCheckReader .queryUtxos(u => u.output.address == scriptAddress) .limit(10) .execute(), Duration.Inf ) .toOption .get .toSeq .sortBy(_._1.index), script, scriptHash, bidder1Address, bidder1Party.signer ) preCheckTx match case scala.Some(tx) => val submitResult = ImmutableEmulator.fromEmulator(emulator).submit(tx) System.err.println( s" pre-check submit: ${submitResult.isRight}, ${submitResult.left.toOption}" ) case _ => System.err.println(s" pre-check: batch tx failed to build") val scenario = ScenarioExplorer.explore(maxDepth = 2) { reader => async[Scenario] { val utxosResult = Await.result( reader.queryUtxos(u => u.output.address == scriptAddress).limit(10).execute(), Duration.Inf ) val auctionUtxos = utxosResult match case Right(u) if u.nonEmpty => u.toSeq.sortBy(_._1.index) case _ => Seq.empty if auctionUtxos.isEmpty then () else if auctionUtxos.size >= 2 then // Multiple auctions available — explore ending strategies val strategy = Scenario.choices("end-first", "end-batch").await strategy match case "end-first" => // End just the first auction buildEndTx(reader, scriptAddress, script, scriptHash) match case scala.Some(tx) => val result = Scenario.submit(tx).await Scenario .check( result.isRight, s"Single end should succeed: $result" ) .await case _ => () case "end-batch" => // End all auctions in one tx with merged seller payment. // This is a natural batching optimization: each validator // independently checks >= its bid, so merged payment // of max(bids) satisfies all validators individually. // A secure contract should reject this. val batchTxOpt = buildBatchEndTx( reader, auctionUtxos, script, scriptHash, bidder1Address, bidder1Party.signer ) System.err.println(s" buildBatchEndTx: ${batchTxOpt.isDefined}") batchTxOpt match case scala.Some(tx) => val result = Scenario.submit(tx).await System.err.println(s" submit result: ${result.isRight}") val datums = auctionUtxos.map(_._2.inlineDatum.get.to[Datum]) val totalOwed = datums.map(_.highestBid.toLong).sum val actualPaid = datums.map(_.highestBid.toLong).max Scenario .check( result.isLeft, s"Batch end with merged seller payment accepted: " + s"pays $actualPaid but owes $totalOwed" ) .await case _ => () case _ => () else // Single remaining auction — just end it buildEndTx(reader, scriptAddress, script, scriptHash) match case scala.Some(tx) => val result = Scenario.submit(tx).await Scenario .check( result.isRight, s"Single end should succeed: $result" ) .await case _ => () } } val results = Await.result(Scenario.runAll(emulator)(scenario), Duration.Inf) System.err.println( s" paths: ${results.size}, time: ${System.currentTimeMillis() - t0}ms" ) val violations = results.flatMap(_._2) assert( violations.nonEmpty, "Checker should detect double satisfaction vulnerability in UnfixedAuction" ) violations.foreach { v => System.err.println(s" VULNERABILITY FOUND: ${v.message}") } } /** State for testing double satisfaction on two auctions with same script */ case class TwoAuctionState( utxo1: Utxo, datum1: Datum, utxo2: Utxo, datum2: Datum, script: PlutusScript, scriptHash: ScriptHash, scriptAddress: CardanoAddress ) /** Step that includes double satisfaction attack variation for vulnerability detection */ class TwoAuctionEndStep( script: PlutusScript, scriptHash: ScriptHash, scriptAddress: CardanoAddress, sponsorAddress: ShelleyAddress, sponsorSigner: scalus.cardano.txbuilder.TransactionSigner, network: Network ) extends ContractStepVariations[TwoAuctionState] { override def extractState( reader: BlockchainReader )(using ExecutionContext): Future[TwoAuctionState] = { reader .queryUtxos { u => u.output.address == scriptAddress } .limit(10) .execute() .map { case Right(utxos) if utxos.size >= 2 => val sorted = utxos.toSeq.sortBy(_._1.index) val (in1, out1) = sorted(0) val (in2, out2) = sorted(1) TwoAuctionState( Utxo(in1, out1), out1.inlineDatum.get.to[Datum], Utxo(in2, out2), out2.inlineDatum.get.to[Datum], script, scriptHash, scriptAddress ) case _ => throw IllegalStateException( s"Need at least 2 auction UTxOs at $scriptAddress" ) } } override def makeBaseTx(reader: BlockchainReader, state: TwoAuctionState)(using ExecutionContext ): Future[TxTemplate] = { // Base tx: end BOTH auctions correctly (pay seller twice) val sellerAddr1 = addressFromPkh(state.datum1.seller, network) val sellerAddr2 = addressFromPkh(state.datum2.seller, network) val winnerAddr1: scala.Option[ShelleyAddress] = state.datum1.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(w) => scala.Some(addressFromPkh(w, network)) case _ => scala.None val winnerAddr2: scala.Option[ShelleyAddress] = state.datum2.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(w) => scala.Some(addressFromPkh(w, network)) case _ => scala.None val nftValue1 = Value.asset(state.scriptHash, AssetName(state.datum1.itemId), 1L) val nftValue2 = Value.asset(state.scriptHash, AssetName(state.datum2.itemId), 1L) val redeemerBuilder1 = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(state.utxo1.input) val sellerIdx = tx.body.value.outputs.indexWhere(_.value.address == sellerAddr1) // Find winner output that has NFT1 val winnerIdx = winnerAddr1 .map { a => tx.body.value.outputs.indexWhere { out => out.value.address == a && out.value.value.hasAsset( state.scriptHash, AssetName(state.datum1.itemId) ) } } .getOrElse(-1) Data.toData(Action.End(BigInt(inputIdx), BigInt(sellerIdx), BigInt(winnerIdx))) } val redeemerBuilder2 = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(state.utxo2.input) // For correct tx, find the SECOND seller output (if same seller) val sellerOutputs = tx.body.value.outputs.zipWithIndex.filter(_._1.value.address == sellerAddr2) val sellerIdx = if sellerAddr1 == sellerAddr2 && sellerOutputs.size > 1 then sellerOutputs(1)._2 else sellerOutputs.headOption.map(_._2).getOrElse(-1) // Find winner output that has NFT2 val winnerIdx = winnerAddr2 .map { a => tx.body.value.outputs.indexWhere { out => out.value.address == a && out.value.value.hasAsset( state.scriptHash, AssetName(state.datum2.itemId) ) } } .getOrElse(-1) Data.toData(Action.End(BigInt(inputIdx), BigInt(sellerIdx), BigInt(winnerIdx))) } var builder = TxBuilder(reader.cardanoInfo) .spend(state.utxo1, redeemerBuilder1, state.script) .spend(state.utxo2, redeemerBuilder2, state.script) .validFrom(Instant.ofEpochMilli(state.datum1.auctionEndTime.toLong + 1000)) // Pay sellers correctly (twice if same seller) and each winner gets their own NFT builder = (winnerAddr1, winnerAddr2) match { case (scala.Some(w1), scala.Some(w2)) if w1 == w2 => // Same winner - but each End expects to see the NFT in a separate output // The validator checks: winnerOutput.value.quantityOf(scriptHash, itemId) === 1 // So we need TWO separate outputs to the winner, each with one NFT builder .payTo( w1, Value.lovelace(2_000_000L) + nftValue1 ) // winner output for auction1 .payTo( w1, Value.lovelace(2_000_000L) + nftValue2 ) // winner output for auction2 .payTo(sellerAddr1, Value.lovelace(state.datum1.highestBid.toLong)) .payTo(sellerAddr2, Value.lovelace(state.datum2.highestBid.toLong)) case (scala.Some(w1), scala.Some(w2)) => builder .payTo(w1, Value.lovelace(2_000_000L) + nftValue1) .payTo(w2, Value.lovelace(2_000_000L) + nftValue2) .payTo(sellerAddr1, Value.lovelace(state.datum1.highestBid.toLong)) .payTo(sellerAddr2, Value.lovelace(state.datum2.highestBid.toLong)) case _ => builder } Future.successful(TxTemplate(builder, sponsorAddress, sponsorSigner)) } override def variations: TxVariations[TwoAuctionState] = { new TxVariations[TwoAuctionState] { override def enumerate( reader: BlockchainReader, state: TwoAuctionState, txTemplate: TxTemplate )(using ExecutionContext): Future[Seq[Transaction]] = { val correctTx = txTemplate.complete(reader) // ATTACK: Double satisfaction - end both auctions, pay seller only ONCE val doubleSatAttackTx = { val sellerAddr = addressFromPkh(state.datum1.seller, network) val winnerAddr1: scala.Option[ShelleyAddress] = state.datum1.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(w) => scala.Some(addressFromPkh(w, network)) case _ => scala.None val winnerAddr2: scala.Option[ShelleyAddress] = state.datum2.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(w) => scala.Some(addressFromPkh(w, network)) case _ => scala.None val nftValue1 = Value.asset(state.scriptHash, AssetName(state.datum1.itemId), 1L) val nftValue2 = Value.asset(state.scriptHash, AssetName(state.datum2.itemId), 1L) // Both redeemers point to the SAME seller output index (the attack!) val attackRedeemerBuilder1 = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(state.utxo1.input) val sellerIdx = tx.body.value.outputs.indexWhere(_.value.address == sellerAddr) // Find winner output that has NFT1 val winnerIdx = winnerAddr1 .map { a => tx.body.value.outputs.indexWhere { out => out.value.address == a && out.value.value.hasAsset( state.scriptHash, AssetName(state.datum1.itemId) ) } } .getOrElse(-1) Data.toData( Action.End(BigInt(inputIdx), BigInt(sellerIdx), BigInt(winnerIdx)) ) } val attackRedeemerBuilder2 = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(state.utxo2.input) // ATTACK: reuse SAME seller output index! val sellerIdx = tx.body.value.outputs.indexWhere(_.value.address == sellerAddr) // Find winner output that has NFT2 val winnerIdx = winnerAddr2 .map { a => tx.body.value.outputs.indexWhere { out => out.value.address == a && out.value.value.hasAsset( state.scriptHash, AssetName(state.datum2.itemId) ) } } .getOrElse(-1) Data.toData( Action.End(BigInt(inputIdx), BigInt(sellerIdx), BigInt(winnerIdx)) ) } var attackBuilder = TxBuilder(reader.cardanoInfo) .spend(state.utxo1, attackRedeemerBuilder1, state.script) .spend(state.utxo2, attackRedeemerBuilder2, state.script) .validFrom( Instant.ofEpochMilli(state.datum1.auctionEndTime.toLong + 1000) ) // Pay seller only ONCE (the bug!) but give winner their NFTs correctly attackBuilder = (winnerAddr1, winnerAddr2) match { case (scala.Some(w1), scala.Some(w2)) if w1 == w2 => // Same winner - needs separate outputs for each NFT (validator checks) attackBuilder .payTo( w1, Value.lovelace(2_000_000L) + nftValue1 ) // winner output for auction1 .payTo( w1, Value.lovelace(2_000_000L) + nftValue2 ) // winner output for auction2 .payTo( sellerAddr, Value.lovelace(state.datum1.highestBid.toLong) ) // Only pay once! (attack) case (scala.Some(w1), scala.Some(w2)) => attackBuilder .payTo(w1, Value.lovelace(2_000_000L) + nftValue1) .payTo(w2, Value.lovelace(2_000_000L) + nftValue2) .payTo( sellerAddr, Value.lovelace(state.datum1.highestBid.toLong) ) // Only pay once! (attack) case _ => attackBuilder } TxTemplate(attackBuilder, txTemplate.sponsor, txTemplate.signer).complete( reader ) } val namedTxs = Seq("correct" -> correctTx, "doubleSatAttack" -> doubleSatAttackTx) Future .sequence(namedTxs.map { case (name, f) => f.map(scala.Some(_)).recover { case e => System.err.println( s" TwoAuctionEndStep.$name failed: ${e.getMessage}" ) scala.None } }) .map { results => val txs = results.flatten txVariationCount.addAndGet(txs.size) txs } } } } } // ========================================================================= // ScenarioExplorer step helpers (kept outside async[Scenario] to avoid // inline size issues) // ========================================================================= private def explorerBidStep( reader: BlockchainReader, script: PlutusScript, scriptHash: ScriptHash, scriptAddress: CardanoAddress, auctionEnd: PosixTime ): Scenario[Unit] = async[Scenario] { val action = Scenario.choices("bid-bob", "bid-charles", "wait").await action match case "bid-bob" => buildBidTx( reader, scriptAddress, script, scriptHash, bidder1Address, bidder1Party.signer ) match case scala.Some(tx) => val result = Scenario.submit(tx).await Scenario.check(result.isRight, s"Bob bid should succeed: $result").await case _ => () case "bid-charles" => buildBidTx( reader, scriptAddress, script, scriptHash, bidder2Address, bidder2Party.signer ) match case scala.Some(tx) => val result = Scenario.submit(tx).await Scenario.check(result.isRight, s"Charles bid should succeed: $result").await case _ => () case "wait" => Scenario.sleep(20).await case _ => () } private def explorerEndStep( reader: BlockchainReader, script: PlutusScript, scriptHash: ScriptHash, scriptAddress: CardanoAddress ): Scenario[Unit] = async[Scenario] { val txOpt = buildEndTx(reader, scriptAddress, script, scriptHash) txOpt match case scala.Some(tx) => val result = Scenario.submit(tx).await Scenario.check(result.isRight, s"End auction should succeed: $result").await case _ => () } private def explorerTwoAuctionsBidStep( reader: BlockchainReader, script1: PlutusScript, scriptHash1: ScriptHash, scriptAddress1: CardanoAddress, script2: PlutusScript, scriptHash2: ScriptHash, scriptAddress2: CardanoAddress, auctionEnd: PosixTime ): Scenario[Unit] = async[Scenario] { val action = Scenario.choices("bid-auction1", "bid-auction2", "wait").await action match case "bid-auction1" => buildBidTx( reader, scriptAddress1, script1, scriptHash1, bidder1Address, bidder1Party.signer ) match case scala.Some(tx) => val result = Scenario.submit(tx).await Scenario .check(result.isRight, s"Bid on auction 1 should succeed: $result") .await case _ => () case "bid-auction2" => buildBidTx( reader, scriptAddress2, script2, scriptHash2, bidder2Address, bidder2Party.signer ) match case scala.Some(tx) => val result = Scenario.submit(tx).await Scenario .check(result.isRight, s"Bid on auction 2 should succeed: $result") .await case _ => () case "wait" => Scenario.sleep(15).await case _ => () } } object AuctionTestKitTest { private given ExecutionContext = ExecutionContext.global val txBuildCount = new java.util.concurrent.atomic.AtomicInteger(0) val txVariationCount = new java.util.concurrent.atomic.AtomicInteger(0) val txSubmitCount = new java.util.concurrent.atomic.AtomicInteger(0) def resetCounters(): Unit = { txBuildCount.set(0) txVariationCount.set(0) txSubmitCount.set(0) } val sellerParty = Alice val bidder1Party = Bob val bidder2Party = Charles val sellerAddress: ShelleyAddress = sellerParty.address(Network.Mainnet) val bidder1Address: ShelleyAddress = bidder1Party.address(Network.Mainnet) val bidder2Address: ShelleyAddress = bidder2Party.address(Network.Mainnet) val itemId = utf8"auction-item-001" val startingBid = 2_000_000L val initialAuctionValue = Coin(5_000_000L) val auctionStartSlot: SlotNo = 10 val auctionEndSlot: SlotNo = 100 /** State for auction contract testing */ case class AuctionState( utxo: Utxo, datum: Datum, script: PlutusScript, scriptHash: ScriptHash, scriptAddress: CardanoAddress ) def extractPkh(address: ShelleyAddress): PubKeyHash = address.payment match case ShelleyPaymentPart.Key(hash) => PubKeyHash(hash) case _ => throw IllegalArgumentException("Address must have key payment credential") def addressFromPkh(pkh: PubKeyHash, network: Network): ShelleyAddress = ShelleyAddress( network, ShelleyPaymentPart.Key(AddrKeyHash.fromByteString(pkh.hash)), ShelleyDelegationPart.Null ) def getAuctionEndTime(info: CardanoInfo): PosixTime = BigInt(info.slotConfig.slotToTime(auctionEndSlot)) def createEmulator(): Emulator = { Emulator( initialUtxos = Map( Input(genesisHash, 0) -> TransactionOutput .Babbage(address = sellerAddress, value = Value.lovelace(10_000_000L)), Input(genesisHash, 1) -> TransactionOutput .Babbage(address = sellerAddress, value = Value.lovelace(100_000_000L)), Input(genesisHash, 2) -> TransactionOutput .Babbage(address = bidder1Address, value = Value.lovelace(100_000_000L)), Input(genesisHash, 3) -> TransactionOutput.Babbage( address = bidder2Address, value = Value.lovelace(100_000_000L) ) ), initialContext = scalus.cardano.ledger.rules.Context.testMainnet(), mutators = Set(PlutusScriptsTransactionMutator) ) } def createEmulatorForTwoAuctions(): Emulator = { Emulator( initialUtxos = Map( Input(genesisHash, 0) -> TransactionOutput .Babbage(address = sellerAddress, value = Value.lovelace(10_000_000L)), Input(genesisHash, 1) -> TransactionOutput .Babbage(address = sellerAddress, value = Value.lovelace(10_000_000L)), Input(genesisHash, 2) -> TransactionOutput .Babbage(address = sellerAddress, value = Value.lovelace(200_000_000L)), Input(genesisHash, 3) -> TransactionOutput .Babbage(address = bidder1Address, value = Value.lovelace(200_000_000L)), Input(genesisHash, 4) -> TransactionOutput.Babbage( address = bidder2Address, value = Value.lovelace(200_000_000L) ) ), initialContext = scalus.cardano.ledger.rules.Context.testMainnet(), mutators = Set(PlutusScriptsTransactionMutator) ) } def startAuction( emulator: Emulator, oneShotUtxo: Utxo, auctionItemId: scalus.uplc.builtin.ByteString = itemId ): (AuctionInstance, PlutusScript, ScriptHash, CardanoAddress) = { val oneShot = TxOutRef( TxId(oneShotUtxo.input.transactionId), BigInt(oneShotUtxo.input.index) ) val factory = AuctionFactory(emulator, withErrorTraces = true) val instance = factory.createInstance(oneShot) val appliedContract = AuctionContract.compiled.withErrorTraces.apply(Data.toData(oneShot)) val script = appliedContract.script val scriptHash = script.scriptHash val scriptAddress = appliedContract.address(emulator.cardanoInfo.network) emulator.setSlot(auctionStartSlot) Await.result( instance.startAuction( sellerAddress = sellerAddress, oneShotUtxo = oneShotUtxo, itemId = auctionItemId, startingBid = startingBid, auctionEndTime = getAuctionEndTime(emulator.cardanoInfo), initialValue = initialAuctionValue, signer = sellerParty.signer ), Duration.Inf ) (instance, script, scriptHash, scriptAddress) } /** Build a bid transaction for the given auction */ def buildBidTx( reader: BlockchainReader, scriptAddress: CardanoAddress, script: PlutusScript, scriptHash: ScriptHash, bidderAddr: ShelleyAddress, bidderSigner: scalus.cardano.txbuilder.TransactionSigner ): scala.Option[Transaction] = { txBuildCount.incrementAndGet() val utxosResult = Await.result( reader.queryUtxos { u => u.output.address == scriptAddress }.limit(1).execute(), Duration.Inf ) utxosResult match case Right(utxos) if utxos.nonEmpty => val (input, output) = utxos.head val auctionUtxo = Utxo(input, output) val datum = output.inlineDatum.get.to[Datum] val bidderPkh = extractPkh(bidderAddr) val newBidAmount = datum.highestBid.toLong + 1_000_000L val newDatum = datum.copy( highestBidder = scalus.cardano.onchain.plutus.prelude.Option.Some(bidderPkh), highestBid = BigInt(newBidAmount) ) val nftAsset = AssetName(datum.itemId) val nftValue = Value.asset(scriptHash, nftAsset, 1L) val prevBidderAddr: scala.Option[ShelleyAddress] = datum.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(prev) => scala.Some(addressFromPkh(prev, reader.cardanoInfo.network)) case _ => scala.None val redeemerBuilder = buildBidRedeemer( auctionUtxo, scriptAddress, prevBidderAddr, newBidAmount, bidderPkh ) var builder = TxBuilder(reader.cardanoInfo) .spend(auctionUtxo, redeemerBuilder, script) .requireSignatures(Set(AddrKeyHash.fromByteString(bidderPkh.hash))) .payTo(scriptAddress, Value.lovelace(newBidAmount) + nftValue, newDatum) .validTo(Instant.ofEpochMilli(datum.auctionEndTime.toLong - 1000)) builder = prevBidderAddr match case scala.Some(addr) => builder.payTo(addr, Value.lovelace(datum.highestBid.toLong)) case scala.None => builder val tx = Await.result( builder.complete(reader, bidderAddr).map(_.sign(bidderSigner).transaction), Duration.Inf ) scala.Some(tx) case _ => scala.None } /** Build an end auction transaction */ def buildEndTx( reader: BlockchainReader, scriptAddress: CardanoAddress, script: PlutusScript, scriptHash: ScriptHash ): scala.Option[Transaction] = { txBuildCount.incrementAndGet() val utxosResult = Await.result( reader.queryUtxos { u => u.output.address == scriptAddress }.limit(1).execute(), Duration.Inf ) utxosResult match case Right(utxos) if utxos.nonEmpty => val (input, output) = utxos.head val auctionUtxo = Utxo(input, output) val datum = output.inlineDatum.get.to[Datum] val nftAsset = AssetName(datum.itemId) val nftValue = Value.asset(scriptHash, nftAsset, 1L) val sellerAddr = addressFromPkh(datum.seller, reader.cardanoInfo.network) val sellerAddrKeyHash = AddrKeyHash.fromByteString(datum.seller.hash) val spendSigners = datum.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(_) => Set.empty[AddrKeyHash] case scalus.cardano.onchain.plutus.prelude.Option.None => Set(sellerAddrKeyHash) val winnerAddr: scala.Option[ShelleyAddress] = datum.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(winner) => scala.Some(addressFromPkh(winner, reader.cardanoInfo.network)) case _ => scala.None val redeemerBuilder = buildEndRedeemer(auctionUtxo, sellerAddr, winnerAddr) var builder = TxBuilder(reader.cardanoInfo) .spend(auctionUtxo, redeemerBuilder, script) .requireSignatures(spendSigners) .validFrom(Instant.ofEpochMilli(datum.auctionEndTime.toLong + 1000)) builder = winnerAddr match case scala.Some(addr) => builder .payTo(addr, Value.lovelace(2_000_000L) + nftValue) .payTo(sellerAddr, Value.lovelace(datum.highestBid.toLong)) case scala.None => builder.payTo(sellerAddr, Value.lovelace(2_000_000L) + nftValue) val tx = Await.result( builder .complete(reader, sellerAddress) .map(_.sign(sellerParty.signer).transaction), Duration.Inf ) scala.Some(tx) case _ => scala.None } /** Build a batch-end transaction that ends all auctions in one tx. * * Uses a natural optimization: merges seller payments into a single output paying max(bids). * Each validator independently checks >= its own bid, so the merged output satisfies all * validators. A secure contract should reject this because the seller is underpaid (gets max * instead of sum). */ def buildBatchEndTx( reader: BlockchainReader, auctionUtxos: Seq[(Input, TransactionOutput)], script: PlutusScript, scriptHash: ScriptHash, sponsorAddress: ShelleyAddress, sponsorSigner: scalus.cardano.txbuilder.TransactionSigner ): scala.Option[Transaction] = { if auctionUtxos.size < 2 then return scala.None val utxosWithDatum = auctionUtxos.map { case (input, output) => (Utxo(input, output), output.inlineDatum.get.to[Datum]) } val network = reader.cardanoInfo.network val sellerAddr = addressFromPkh(utxosWithDatum.head._2.seller, network) var builder = TxBuilder(reader.cardanoInfo) .validFrom(Instant.ofEpochMilli(utxosWithDatum.head._2.auctionEndTime.toLong + 1000)) .minFee(Coin(600_000L)) // ensure fee buffer for multi-script batch tx // Spend each auction UTxO with delayed redeemer that computes indices for (utxo, datum) <- utxosWithDatum do val redeemerBuilder = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(utxo.input) val sellerIdx = tx.body.value.outputs.indexWhere(_.value.address == sellerAddr) val winnerAddr = datum.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(w) => addressFromPkh(w, network) case _ => throw IllegalStateException("Expected winner") val winnerIdx = tx.body.value.outputs.indexWhere { out => out.value.address == winnerAddr && out.value.value.hasAsset(scriptHash, AssetName(datum.itemId)) } Data.toData(Action.End(BigInt(inputIdx), BigInt(sellerIdx), BigInt(winnerIdx))) } builder = builder.spend(utxo, redeemerBuilder, script) // Winner outputs: each auction's NFT goes to its winner separately for (_, datum) <- utxosWithDatum do val winnerAddr = datum.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(w) => addressFromPkh(w, network) case _ => throw IllegalStateException("Expected winner") val nftValue = Value.asset(scriptHash, AssetName(datum.itemId), 1L) builder = builder.payTo(winnerAddr, Value.lovelace(2_000_000L) + nftValue) // Merged seller payment: pay max(bids) in one output. // Each validator checks >= its own bid, so max satisfies all individually. val mergedPayment = utxosWithDatum.map(_._2.highestBid.toLong).max builder = builder.payTo(sellerAddr, Value.lovelace(mergedPayment)) scala.util.Try { Await.result( TxTemplate(builder, sponsorAddress, sponsorSigner).complete(reader)(using reader.executionContext ), Duration.Inf ) } match case scala.util.Success(tx) => scala.Some(tx) case scala.util.Failure(e) => System.err.println(s" buildBatchEndTx failed: ${e.getMessage}") scala.None } private def buildBidRedeemer( auctionUtxo: Utxo, scriptAddress: CardanoAddress, prevBidderAddr: scala.Option[ShelleyAddress], newBidAmount: Long, bidderPkh: PubKeyHash ): Transaction => scalus.uplc.builtin.Data = { (tx: Transaction) => val inputIdx = tx.body.value.inputs.toSeq.indexOf(auctionUtxo.input) val outputIdx = tx.body.value.outputs.indexWhere(_.value.address == scriptAddress) val refundIdx = prevBidderAddr .map(addr => tx.body.value.outputs.indexWhere(_.value.address == addr)) .getOrElse(-1) Data.toData( Action.Bid( BigInt(newBidAmount), bidderPkh, BigInt(inputIdx), BigInt(outputIdx), BigInt(refundIdx) ) ) } private def buildEndRedeemer( auctionUtxo: Utxo, sellerAddr: ShelleyAddress, winnerAddr: scala.Option[ShelleyAddress] ): Transaction => scalus.uplc.builtin.Data = { (tx: Transaction) => val inputIdx = tx.body.value.inputs.toSeq.indexOf(auctionUtxo.input) val sellerOutputIdx = tx.body.value.outputs.indexWhere(_.value.address == sellerAddr) val winnerOutputIdx = winnerAddr .map(addr => tx.body.value.outputs.indexWhere(_.value.address == addr)) .getOrElse(-1) Data.toData( Action.End(BigInt(inputIdx), BigInt(sellerOutputIdx), BigInt(winnerOutputIdx)) ) } // ========================================================================= // ContractStepVariations implementations // ========================================================================= class AuctionBidStep( script: PlutusScript, scriptHash: ScriptHash, scriptAddress: CardanoAddress, bidderAddress: ShelleyAddress, bidderSigner: scalus.cardano.txbuilder.TransactionSigner, bidAmount: Long ) extends ContractStepVariations[AuctionState] { override def extractState(reader: BlockchainReader)(using ExecutionContext ): Future[AuctionState] = { reader .queryUtxos { u => u.output.address == scriptAddress } .limit(1) .execute() .map { case Right(utxos) if utxos.nonEmpty => val (input, output) = utxos.head val utxo = Utxo(input, output) val datum = output.inlineDatum .getOrElse(throw IllegalStateException("No inline datum")) .to[Datum] AuctionState(utxo, datum, script, scriptHash, scriptAddress) case _ => throw IllegalStateException(s"No auction UTxO found at $scriptAddress") } } override def makeBaseTx(reader: BlockchainReader, state: AuctionState)(using ExecutionContext ): Future[TxTemplate] = { val bidderPkh = extractPkh(bidderAddress) val newDatum = state.datum.copy( highestBidder = scalus.cardano.onchain.plutus.prelude.Option.Some(bidderPkh), highestBid = BigInt(bidAmount) ) val nftAsset = AssetName(state.datum.itemId) val nftValue = Value.asset(state.scriptHash, nftAsset, 1L) val newAuctionValue = Value.lovelace(bidAmount) + nftValue val prevBidderAddr: scala.Option[ShelleyAddress] = state.datum.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(prevBidder) => scala.Some(addressFromPkh(prevBidder, reader.cardanoInfo.network)) case scalus.cardano.onchain.plutus.prelude.Option.None => scala.None val redeemerBuilder = buildBidRedeemer( state.utxo, state.scriptAddress, prevBidderAddr, bidAmount, bidderPkh ) var builder = TxBuilder(reader.cardanoInfo) .spend(state.utxo, redeemerBuilder, state.script) .requireSignatures(Set(AddrKeyHash.fromByteString(bidderPkh.hash))) .payTo(state.scriptAddress, newAuctionValue, newDatum) .validTo(Instant.ofEpochMilli(state.datum.auctionEndTime.toLong - 1000)) builder = prevBidderAddr match case scala.Some(addr) => builder.payTo(addr, Value.lovelace(state.datum.highestBid.toLong)) case scala.None => builder Future.successful(TxTemplate(builder, bidderAddress, bidderSigner)) } override def variations: TxVariations[AuctionState] = { new TxVariations[AuctionState] { override def enumerate( reader: BlockchainReader, state: AuctionState, txTemplate: TxTemplate )(using ExecutionContext): Future[Seq[Transaction]] = { val correctTx = txTemplate.complete(reader) val stealOutputTx = { val bidderPkh = extractPkh(bidderAddress) val redeemerBuilder = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(state.utxo.input) Data.toData( Action.Bid( BigInt(bidAmount), bidderPkh, BigInt(inputIdx), BigInt(0), BigInt(-1) ) ) } val stealBuilder = TxBuilder(reader.cardanoInfo) .spend(state.utxo, redeemerBuilder, state.script) .requireSignatures(Set(AddrKeyHash.fromByteString(bidderPkh.hash))) .payTo(bidderAddress, Value.lovelace(bidAmount)) .validTo(Instant.ofEpochMilli(state.datum.auctionEndTime.toLong - 1000)) TxTemplate(stealBuilder, bidderAddress, bidderSigner).complete(reader) } val bidTooLowTx = { val lowBid = state.datum.highestBid.toLong - 1 val bidderPkh = extractPkh(bidderAddress) val newDatum = state.datum.copy( highestBidder = scalus.cardano.onchain.plutus.prelude.Option.Some(bidderPkh), highestBid = BigInt(lowBid) ) val nftAsset = AssetName(state.datum.itemId) val nftValue = Value.asset(state.scriptHash, nftAsset, 1L) val redeemerBuilder = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(state.utxo.input) val outputIdx = tx.body.value.outputs .indexWhere(_.value.address == state.scriptAddress) Data.toData( Action.Bid( BigInt(lowBid), bidderPkh, BigInt(inputIdx), BigInt(outputIdx), BigInt(-1) ) ) } val lowBuilder = TxBuilder(reader.cardanoInfo) .spend(state.utxo, redeemerBuilder, state.script) .requireSignatures(Set(AddrKeyHash.fromByteString(bidderPkh.hash))) .payTo(state.scriptAddress, Value.lovelace(lowBid) + nftValue, newDatum) .validTo(Instant.ofEpochMilli(state.datum.auctionEndTime.toLong - 1000)) TxTemplate(lowBuilder, bidderAddress, bidderSigner).complete(reader) } val corruptDatumTx = { val bidderPkh = extractPkh(bidderAddress) val corruptDatum = state.datum.copy(highestBid = BigInt(999_999_999L)) val nftAsset = AssetName(state.datum.itemId) val nftValue = Value.asset(state.scriptHash, nftAsset, 1L) val redeemerBuilder = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(state.utxo.input) val outputIdx = tx.body.value.outputs .indexWhere(_.value.address == state.scriptAddress) Data.toData( Action.Bid( BigInt(bidAmount), bidderPkh, BigInt(inputIdx), BigInt(outputIdx), BigInt(-1) ) ) } val corruptBuilder = TxBuilder(reader.cardanoInfo) .spend(state.utxo, redeemerBuilder, state.script) .requireSignatures(Set(AddrKeyHash.fromByteString(bidderPkh.hash))) .payTo( state.scriptAddress, Value.lovelace(bidAmount) + nftValue, corruptDatum ) .validTo(Instant.ofEpochMilli(state.datum.auctionEndTime.toLong - 1000)) TxTemplate(corruptBuilder, bidderAddress, bidderSigner).complete(reader) } val namedTxs = Seq( "correct" -> correctTx, "stealOutput" -> stealOutputTx, "bidTooLow" -> bidTooLowTx, "corruptDatum" -> corruptDatumTx ) Future .sequence(namedTxs.map { case (_, f) => f.map(scala.Some(_)).recover { case _ => scala.None } }) .map { results => val txs = results.flatten txVariationCount.addAndGet(txs.size) txs } } } } override def slotDelays(state: AuctionState): Seq[Long] = Seq(10L, 500L) } class AuctionEndStep( script: PlutusScript, scriptHash: ScriptHash, scriptAddress: CardanoAddress, sponsorAddress: ShelleyAddress, sponsorSigner: scalus.cardano.txbuilder.TransactionSigner, network: Network ) extends ContractStepVariations[AuctionState] { override def extractState(reader: BlockchainReader)(using ExecutionContext ): Future[AuctionState] = { reader .queryUtxos { u => u.output.address == scriptAddress } .limit(1) .execute() .map { case Right(utxos) if utxos.nonEmpty => val (input, output) = utxos.head val utxo = Utxo(input, output) val datum = output.inlineDatum .getOrElse(throw IllegalStateException("No inline datum")) .to[Datum] AuctionState(utxo, datum, script, scriptHash, scriptAddress) case _ => throw IllegalStateException(s"No auction UTxO found at $scriptAddress") } } override def makeBaseTx(reader: BlockchainReader, state: AuctionState)(using ExecutionContext ): Future[TxTemplate] = { val nftAsset = AssetName(state.datum.itemId) val nftValue = Value.asset(state.scriptHash, nftAsset, 1L) val sellerAddr = addressFromPkh(state.datum.seller, network) val sellerAddrKeyHash = AddrKeyHash.fromByteString(state.datum.seller.hash) val spendRequiredSigners = state.datum.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(_) => Set.empty[AddrKeyHash] case scalus.cardano.onchain.plutus.prelude.Option.None => Set(sellerAddrKeyHash) val winnerAddr: scala.Option[ShelleyAddress] = state.datum.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(winner) => scala.Some(addressFromPkh(winner, network)) case scalus.cardano.onchain.plutus.prelude.Option.None => scala.None val redeemerBuilder = buildEndRedeemer(state.utxo, sellerAddr, winnerAddr) var builder = TxBuilder(reader.cardanoInfo) .spend(state.utxo, redeemerBuilder, state.script) .requireSignatures(spendRequiredSigners) .validFrom(Instant.ofEpochMilli(state.datum.auctionEndTime.toLong + 1000)) builder = winnerAddr match case scala.Some(addr) => builder .payTo(addr, Value.lovelace(2_000_000L) + nftValue) // Seller payout is tagged with this auction's scriptHash (anti-DS). .payTo( sellerAddr, Value.lovelace(state.datum.highestBid.toLong), scriptHash: scalus.uplc.builtin.ByteString ) case scala.None => builder.payTo(sellerAddr, Value.lovelace(2_000_000L) + nftValue) Future.successful(TxTemplate(builder, sponsorAddress, sponsorSigner)) } override def variations: TxVariations[AuctionState] = { new TxVariations[AuctionState] { override def enumerate( reader: BlockchainReader, state: AuctionState, txTemplate: TxTemplate )(using ExecutionContext): Future[Seq[Transaction]] = { val correctTx = txTemplate.complete(reader) val stealSellerTx = state.datum.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(winner) => val winnerAddr = addressFromPkh(winner, network) val nftAsset = AssetName(state.datum.itemId) val nftValue = Value.asset(state.scriptHash, nftAsset, 1L) val redeemerBuilder = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(state.utxo.input) val winnerOutputIdx = tx.body.value.outputs.indexWhere(_.value.address == winnerAddr) Data.toData( Action.End(BigInt(inputIdx), BigInt(-1), BigInt(winnerOutputIdx)) ) } val stealBuilder = TxBuilder(reader.cardanoInfo) .spend(state.utxo, redeemerBuilder, state.script) .payTo(winnerAddr, Value.lovelace(2_000_000L) + nftValue) .validFrom( Instant.ofEpochMilli(state.datum.auctionEndTime.toLong + 1000) ) scala.Some( TxTemplate(stealBuilder, sponsorAddress, sponsorSigner).complete( reader ) ) case _ => scala.None val nftWrongAddrTx = state.datum.highestBidder match case scalus.cardano.onchain.plutus.prelude.Option.Some(_) => val sellerAddr = addressFromPkh(state.datum.seller, network) val nftAsset = AssetName(state.datum.itemId) val nftValue = Value.asset(state.scriptHash, nftAsset, 1L) val redeemerBuilder = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(state.utxo.input) val sellerOutputIdx = tx.body.value.outputs.indexWhere(_.value.address == sellerAddr) val attackerOutputIdx = tx.body.value.outputs .indexWhere(_.value.address == bidder2Address) Data.toData( Action.End( BigInt(inputIdx), BigInt(sellerOutputIdx), BigInt(attackerOutputIdx) ) ) } val wrongAddrBuilder = TxBuilder(reader.cardanoInfo) .spend(state.utxo, redeemerBuilder, state.script) .payTo(bidder2Address, Value.lovelace(2_000_000L) + nftValue) .payTo(sellerAddr, Value.lovelace(state.datum.highestBid.toLong)) .validFrom( Instant.ofEpochMilli(state.datum.auctionEndTime.toLong + 1000) ) scala.Some( TxTemplate(wrongAddrBuilder, sponsorAddress, sponsorSigner).complete( reader ) ) case _ => scala.None val allTxs = Seq(scala.Some(correctTx)) ++ Seq(stealSellerTx, nftWrongAddrTx) Future .sequence(allTxs.flatten.map(_.recover { case _ => null })) .map { results => val txs = results.filter(_ != null) txVariationCount.addAndGet(txs.size) txs } } } } } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/auction/AuctionValidatorTest.scala ```scala package scalus.examples.auction import org.scalatest.funsuite.AnyFunSuite import scalus.ScalaCompilerVersion import scalus.uplc.builtin.ByteString.* import scalus.cardano.address.ShelleyAddress import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.* import scalus.cardano.ledger.utils.AllResolvedScripts import scalus.cardano.node.Emulator import scalus.cardano.txbuilder.RedeemerPurpose import scalus.cardano.onchain.plutus.v1.PosixTime import scalus.testing.kit.ScalusTest import scalus.testing.kit.TestUtil.{genesisHash, getScriptContextV3} import scalus.testing.kit.Party.{Alice, Bob, Charles} import scalus.uplc.eval.{ProfileFormatter, Result} import scalus.utils.await class AuctionValidatorTest extends AnyFunSuite, ScalusTest { import AuctionValidatorTest.* test(s"Auction validator size is ${AuctionContract.compiled.script.script.size} bytes") { println(s"Auction validator size: ${AuctionContract.compiled.script.script.size} bytes") assert(AuctionContract.compiled.script.script.size > 0) } test("seller can start auction") { TestCase( action = TestAction.Start, expected = Expected.Success ).run() } test("first bidder can place bid") { TestCase( action = TestAction.Bid(bidAmount = 3_000_000L), expected = Expected.Success ).run() } test("second bidder can outbid with refund to first bidder") { TestCase( action = TestAction.Outbid(newBidAmount = 5_000_000L), expected = Expected.Success ).run() } test("auction can end with winner") { TestCase( action = TestAction.EndWithWinner, expected = Expected.Success ).run() } test("seller can end auction without bids") { TestCase( action = TestAction.EndNoBids, expected = Expected.Success ).run() } // Budget assertion tests - limits should decrease as the compiler improves test("budget: first bid") { val budget = TestCase( action = TestAction.Bid(bidAmount = 3_000_000L), expected = Expected.Success ).runWithBudget() assert( budget == ScalaCompilerVersion.baseline( pre38 = ExUnits(memory = 143907, steps = 46_063204), since38 = ExUnits(memory = 140851, steps = 44_985342) ) ) } test("budget: outbid with refund") { val budget = TestCase( action = TestAction.Outbid(newBidAmount = 5_000_000L), expected = Expected.Success ).runWithBudget() assert( budget == ScalaCompilerVersion.baseline( pre38 = ExUnits(memory = 183234, steps = 57_159485), since38 = ExUnits(memory = 180178, steps = 56_081623) ) ) } test("budget: end auction with winner") { val budget = TestCase( action = TestAction.EndWithWinner, expected = Expected.Success ).runWithBudget() assert( budget == ScalaCompilerVersion.baseline( pre38 = ExUnits(memory = 232001, steps = 68_910046), since38 = ExUnits(memory = 228945, steps = 67_832184) ) ) } test("budget: end auction without bids") { val budget = TestCase( action = TestAction.EndNoBids, expected = Expected.Success ).runWithBudget() assert( budget == ScalaCompilerVersion.baseline( pre38 = ExUnits(memory = 188880, steps = 54_132450), since38 = ExUnits(memory = 185824, steps = 53_054588) ) ) } } object AuctionValidatorTest extends ScalusTest { import scalus.cardano.onchain.plutus.v3.{TxId, TxOutRef} import scalus.cardano.node.BlockchainProvider import scalus.cardano.address.Network // Emit an interactive HTML profile (target/auction-profile.html) when a validator runs. // Off by default so the suite stays fast and side-effect free; enable with env // `SCALUS_PROFILE=1` (env so it reaches the forked test JVM) or `-Dscalus.profile=true`. private val profilingEnabled = sys.env.get("SCALUS_PROFILE").contains("1") || sys.props.get("scalus.profile").contains("true") // Party to role mapping private val sellerParty = Alice private val bidder1Party = Bob private val bidder2Party = Charles private val sellerAddress: ShelleyAddress = sellerParty.address(Network.Mainnet) private val bidder1Address: ShelleyAddress = bidder1Party.address(Network.Mainnet) private val bidder2Address: ShelleyAddress = bidder2Party.address(Network.Mainnet) private val itemId = utf8"auction-item-001" private val startingBid = 2_000_000L private val initialAuctionValue = Coin(5_000_000L) private val slot: SlotNo = 100 private val beforeSlot: SlotNo = slot - 10 private val afterSlot: SlotNo = slot + 10 // auctionEndTime computed per-test from provider.cardanoInfo.slotConfig /** Create an AuctionInstance using the first seller UTxO as the oneShot parameter. Returns both * the instance and the UTxO that must be spent in startAuction. */ private def createAuctionInstanceWithUtxo(provider: Emulator): (AuctionInstance, Utxo) = { val sellerUtxos = provider.findUtxos(sellerAddress).await().toOption.get val oneShotUtxo = Utxo(sellerUtxos.head) val oneShot = TxOutRef( TxId(oneShotUtxo.input.transactionId), BigInt(oneShotUtxo.input.index) ) val factory = AuctionFactory(provider, withErrorTraces = true) (factory.createInstance(oneShot), oneShotUtxo) } private def getAuctionEndTime(provider: BlockchainProvider): PosixTime = BigInt(provider.cardanoInfo.slotConfig.slotToTime(slot)) enum TestAction: case Start case Bid(bidAmount: Long) case Outbid(newBidAmount: Long) case EndWithWinner case EndNoBids enum Expected: case Success case Failure(errorContains: String) case class TestCase( action: TestAction, expected: Expected ): def run(): Unit = val provider = createProvider() val (auction, oneShotUtxo) = createAuctionInstanceWithUtxo(provider) action match case TestAction.Start => runStartTest(provider, auction, oneShotUtxo) case TestAction.Bid(bidAmount) => runBidTest(provider, auction, oneShotUtxo, bidAmount) case TestAction.Outbid(newBidAmount) => runOutbidTest(provider, auction, oneShotUtxo, newBidAmount) case TestAction.EndWithWinner => runEndWithWinnerTest(provider, auction, oneShotUtxo) case TestAction.EndNoBids => runEndNoBidsTest(provider, auction, oneShotUtxo) private def runStartTest( provider: Emulator, auction: AuctionInstance, oneShotUtxo: Utxo ): Unit = provider.setSlot(beforeSlot) val result = scala.util.Try { auction .startAuction( sellerAddress = sellerAddress, oneShotUtxo = oneShotUtxo, itemId = itemId, startingBid = startingBid, auctionEndTime = getAuctionEndTime(provider), initialValue = initialAuctionValue, signer = sellerParty.signer ) .await() } verifyResult(result) private def runBidTest( provider: Emulator, auction: AuctionInstance, oneShotUtxo: Utxo, bidAmount: Long ): Unit = // First start the auction provider.setSlot(beforeSlot) auction .startAuction( sellerAddress = sellerAddress, oneShotUtxo = oneShotUtxo, itemId = itemId, startingBid = startingBid, auctionEndTime = getAuctionEndTime(provider), initialValue = initialAuctionValue, signer = sellerParty.signer ) .await() // Then place bid val result = scala.util.Try { auction .bid( bidderAddress = bidder1Address, bidAmount = bidAmount, itemId = itemId, signer = bidder1Party.signer ) .await() } verifyResult(result) private def runOutbidTest( provider: Emulator, auction: AuctionInstance, oneShotUtxo: Utxo, newBidAmount: Long ): Unit = // Start auction provider.setSlot(beforeSlot) auction .startAuction( sellerAddress = sellerAddress, oneShotUtxo = oneShotUtxo, itemId = itemId, startingBid = startingBid, auctionEndTime = getAuctionEndTime(provider), initialValue = initialAuctionValue, signer = sellerParty.signer ) .await() // First bid auction .bid( bidderAddress = bidder1Address, bidAmount = 3_000_000L, itemId = itemId, signer = bidder1Party.signer ) .await() // Outbid val result = scala.util.Try { auction .bid( bidderAddress = bidder2Address, bidAmount = newBidAmount, itemId = itemId, signer = bidder2Party.signer ) .await() } verifyResult(result) private def runEndWithWinnerTest( provider: Emulator, auction: AuctionInstance, oneShotUtxo: Utxo ): Unit = // Start auction provider.setSlot(beforeSlot) auction .startAuction( sellerAddress = sellerAddress, oneShotUtxo = oneShotUtxo, itemId = itemId, startingBid = startingBid, auctionEndTime = getAuctionEndTime(provider), initialValue = initialAuctionValue, signer = sellerParty.signer ) .await() // Place bid auction .bid( bidderAddress = bidder1Address, bidAmount = 3_000_000L, itemId = itemId, signer = bidder1Party.signer ) .await() // End auction after time provider.setSlot(afterSlot) val result = scala.util.Try { auction .endAuction( sponsorAddress = sellerAddress, itemId = itemId, signer = sellerParty.signer ) .await() } verifyResult(result) private def runEndNoBidsTest( provider: Emulator, auction: AuctionInstance, oneShotUtxo: Utxo ): Unit = // Start auction provider.setSlot(beforeSlot) auction .startAuction( sellerAddress = sellerAddress, oneShotUtxo = oneShotUtxo, itemId = itemId, startingBid = startingBid, auctionEndTime = getAuctionEndTime(provider), initialValue = initialAuctionValue, signer = sellerParty.signer ) .await() // End auction after time (no bids) provider.setSlot(afterSlot) val result = scala.util.Try { auction .endAuction( sponsorAddress = sellerAddress, itemId = itemId, signer = sellerParty.signer ) .await() } verifyResult(result) private def verifyResult(result: scala.util.Try[Transaction]): Unit = expected match case Expected.Success => assert( result.isSuccess, s"Should succeed but failed: ${result.failed.getOrElse("unknown")}" ) case Expected.Failure(errorContains) => assert(result.isFailure, "Should fail but succeeded") val errorMsg = result.failed.get.getMessage assert( errorMsg.contains(errorContains), s"Expected error '$errorContains' but got '$errorMsg'" ) /** Run the test and return the execution budget for spend validators */ def runWithBudget(): ExUnits = val provider = createProvider() val (auction, oneShotUtxo) = createAuctionInstanceWithUtxo(provider) action match case TestAction.Start => throw RuntimeException("Start action uses mint, not spend - no budget test") case TestAction.Bid(bidAmount) => runBidWithBudget(provider, auction, oneShotUtxo, bidAmount) case TestAction.Outbid(newBidAmount) => runOutbidWithBudget(provider, auction, oneShotUtxo, newBidAmount) case TestAction.EndWithWinner => runEndWithWinnerWithBudget(provider, auction, oneShotUtxo) case TestAction.EndNoBids => runEndNoBidsWithBudget(provider, auction, oneShotUtxo) private def runBidWithBudget( provider: Emulator, auction: AuctionInstance, oneShotUtxo: Utxo, bidAmount: Long ): ExUnits = provider.setSlot(beforeSlot) auction .startAuction( sellerAddress = sellerAddress, oneShotUtxo = oneShotUtxo, itemId = itemId, startingBid = startingBid, auctionEndTime = getAuctionEndTime(provider), initialValue = initialAuctionValue, signer = sellerParty.signer ) .await() // Capture the UTxO BEFORE the bid transaction consumes it val auctionUtxo = Utxo( provider.findUtxos(auction.scriptAddress).await().toOption.get.head ) // Save the utxo map before submission val utxosBeforeBid = Map(auctionUtxo.toTuple) val tx = auction .bid( bidderAddress = bidder1Address, bidAmount = bidAmount, itemId = itemId, signer = bidder1Party.signer ) .await() runValidatorWithUtxos( provider, auction, tx, auctionUtxo.input, utxosBeforeBid, "First bid" ).budget private def runOutbidWithBudget( provider: Emulator, auction: AuctionInstance, oneShotUtxo: Utxo, newBidAmount: Long ): ExUnits = provider.setSlot(beforeSlot) auction .startAuction( sellerAddress = sellerAddress, oneShotUtxo = oneShotUtxo, itemId = itemId, startingBid = startingBid, auctionEndTime = getAuctionEndTime(provider), initialValue = initialAuctionValue, signer = sellerParty.signer ) .await() auction .bid( bidderAddress = bidder1Address, bidAmount = 3_000_000L, itemId = itemId, signer = bidder1Party.signer ) .await() // Capture ALL UTxOs BEFORE the outbid transaction consumes them val auctionUtxo = Utxo( provider.findUtxos(auction.scriptAddress).await().toOption.get.head ) // Get all UTxOs from the provider before the transaction val allUtxosBeforeOutbid = provider.utxos val tx = auction .bid( bidderAddress = bidder2Address, bidAmount = newBidAmount, itemId = itemId, signer = bidder2Party.signer ) .await() runValidatorWithUtxos( provider, auction, tx, auctionUtxo.input, allUtxosBeforeOutbid, "Outbid with refund" ).budget private def runEndWithWinnerWithBudget( provider: Emulator, auction: AuctionInstance, oneShotUtxo: Utxo ): ExUnits = provider.setSlot(beforeSlot) auction .startAuction( sellerAddress = sellerAddress, oneShotUtxo = oneShotUtxo, itemId = itemId, startingBid = startingBid, auctionEndTime = getAuctionEndTime(provider), initialValue = initialAuctionValue, signer = sellerParty.signer ) .await() auction .bid( bidderAddress = bidder1Address, bidAmount = 3_000_000L, itemId = itemId, signer = bidder1Party.signer ) .await() // Capture the UTxO BEFORE the end transaction consumes it val auctionUtxo = Utxo( provider.findUtxos(auction.scriptAddress).await().toOption.get.head ) // Get all UTxOs from the provider before the transaction val allUtxosBeforeEnd = provider.utxos provider.setSlot(afterSlot) val tx = auction .endAuction( sponsorAddress = sellerAddress, itemId = itemId, signer = sellerParty.signer ) .await() runValidatorWithUtxos( provider, auction, tx, auctionUtxo.input, allUtxosBeforeEnd, "End auction with winner" ).budget private def runEndNoBidsWithBudget( provider: Emulator, auction: AuctionInstance, oneShotUtxo: Utxo ): ExUnits = provider.setSlot(beforeSlot) auction .startAuction( sellerAddress = sellerAddress, oneShotUtxo = oneShotUtxo, itemId = itemId, startingBid = startingBid, auctionEndTime = getAuctionEndTime(provider), initialValue = initialAuctionValue, signer = sellerParty.signer ) .await() // Capture the UTxO BEFORE the end transaction consumes it val auctionUtxo = Utxo( provider.findUtxos(auction.scriptAddress).await().toOption.get.head ) // Save the utxo map before submission val utxosBeforeEnd = Map(auctionUtxo.toTuple) provider.setSlot(afterSlot) val tx = auction .endAuction( sponsorAddress = sellerAddress, itemId = itemId, signer = sellerParty.signer ) .await() runValidatorWithUtxos( provider, auction, tx, auctionUtxo.input, utxosBeforeEnd, "End auction without bids" ).budget /** Run validator with pre-captured UTxOs (for when the transaction has already been submitted) */ private def runValidatorWithUtxos( provider: Emulator, auction: AuctionInstance, tx: Transaction, scriptInput: TransactionInput, knownUtxos: Map[TransactionInput, TransactionOutput], label: String = "" ): Result = given CardanoInfo = provider.cardanoInfo // Merge known utxos with any remaining utxos from provider val body = tx.body.value val allInputs = (body.inputs.toSet.view ++ body.collateralInputs.toSet.view ++ body.referenceInputs.toSet.view).toSet val remainingInputs = allInputs -- knownUtxos.keySet val providerUtxos = if remainingInputs.nonEmpty then provider.findUtxos(remainingInputs).await().toOption.getOrElse(Map.empty) else Map.empty val utxos = knownUtxos ++ providerUtxos val scriptContext = tx.getScriptContextV3(utxos, RedeemerPurpose.ForSpend(scriptInput)) val allResolvedPlutusScriptsMap = AllResolvedScripts.allResolvedPlutusScriptsMap(tx, utxos).toOption.get val plutusScript = auction.scriptAddress.scriptHashOption.flatMap(allResolvedPlutusScriptsMap.get).get val program = plutusScript.deBruijnedProgram.toProgram val result = program.runWithDebug(scriptContext) assert(result.isSuccess, s"Validator failed: $result, logs: ${result.logs.mkString(", ")}") // Set `profilingEnabled = true` to emit an interactive profile of this validator. The // `include` filter keeps only the example's own sources (skipping inlined framework code). if profilingEnabled then program.runWithProfile(scriptContext).profile.foreach { rawProfile => // Attach prices so the report derives a per-entry on-chain fee (lovelace). val p = rawProfile.withPrices(provider.cardanoInfo.protocolParams.executionUnitPrices) println(ProfileFormatter.summary(p)) ProfileFormatter.writeHtml( p, "target/auction-profile.html", include = f => !f.contains("/scalus-core/") && !f.contains("/scalus-cardano-ledger/"), title = if label.isEmpty then "AuctionValidator" else s"AuctionValidator — $label" ) // Machine-readable sibling consumed by the Scalus VS Code extension. ProfileFormatter.writeJson(p, "target/auction-profile.json") println("Wrote profile to target/auction-profile.html and .json") } result private def createProvider(): Emulator = Emulator( initialUtxos = Map( // Seller gets two UTxOs: one for oneShot, one for fees/collateral Input(genesisHash, 0) -> TransactionOutput.Babbage( address = sellerAddress, value = Value.lovelace(10_000_000L) // oneShot UTxO ), Input(genesisHash, 1) -> TransactionOutput.Babbage( address = sellerAddress, value = Value.lovelace(100_000_000L) // fees/collateral ), Input(genesisHash, 2) -> TransactionOutput.Babbage( address = bidder1Address, value = Value.lovelace(100_000_000L) ), Input(genesisHash, 3) -> TransactionOutput.Babbage( address = bidder2Address, value = Value.lovelace(100_000_000L) ) ), initialContext = Context.testMainnet(), mutators = Set(PlutusScriptsTransactionMutator) ) } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/auction/CrossInstanceDoubleSatisfactionTest.scala ```scala package scalus.examples.auction import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.ByteString.utf8 import scalus.uplc.builtin.ByteString import scalus.uplc.builtin.Data.toData import scalus.cardano.onchain.plutus.v1.{Address, Credential, PosixTime} import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.v3.ScriptInfo.SpendingScript import scalus.cardano.onchain.plutus.prelude.{List as SList, Option as SOption} import scalus.testing.kit.{ScalusTest, TestUtil} /** Cross-instance double-satisfaction test using two *genuinely distinct* auction instances. * * Each auction is one-shot-parameterized, so it has its own script hash and address. The per-hash * "exactly one auction NFT spent" guard in `handleEnd` only sees its own input and cannot detect a * sibling auction at a different address. The seller-output id tag (the auction's scriptHash) is * what blocks the attack: a single shared seller output can carry only one auction's tag. * * Before the fix, both validators passed this attack (each saw one NFT under its own hash and a * seller output >= its own bid), paying the seller once for two auctions. */ class CrossInstanceDoubleSatisfactionTest extends AnyFunSuite, ScalusTest { // Two distinct one-shot params => two distinct script hashes/addresses. private val oneShotA = TxOutRef(TxId(ByteString.fromHex("aa" * 32)), 0) private val oneShotB = TxOutRef(TxId(ByteString.fromHex("bb" * 32)), 0) private val instA = AuctionContract.compiled.withErrorTraces.apply(oneShotA.toData) private val instB = AuctionContract.compiled.withErrorTraces.apply(oneShotB.toData) private val hashA: ByteString = instA.script.scriptHash private val hashB: ByteString = instB.script.scriptHash private val seller = TestUtil.mockPubKeyHash(1) private val buyer = TestUtil.mockPubKeyHash(2) private val itemIdA = utf8"item-A" private val itemIdB = utf8"item-B" private val bid = BigInt(10_000_000) private val endTime: PosixTime = BigInt(1000) private def wonDatum(itemId: ByteString) = Datum(seller, SOption.Some(buyer), bid, endTime, itemId) test("cross-instance double satisfaction is blocked across distinct one-shot auctions") { val outRefA = TxOutRef(TxId(ByteString.fromHex("01" * 32)), 0) val outRefB = TxOutRef(TxId(ByteString.fromHex("02" * 32)), 0) val inputA = TxInInfo( outRefA, TxOut( address = Address.fromScriptHash(hashA), value = Value.lovelace(bid + 2_000_000) + Value(hashA, itemIdA, 1), datum = OutputDatum.OutputDatum(wonDatum(itemIdA).toData) ) ) val inputB = TxInInfo( outRefB, TxOut( address = Address.fromScriptHash(hashB), value = Value.lovelace(bid + 2_000_000) + Value(hashB, itemIdB, 1), datum = OutputDatum.OutputDatum(wonDatum(itemIdB).toData) ) ) // ATTACK: a single shared seller output (tagged with auction A's id only), and the winner // collects both NFTs. The attacker keeps auction B's bid. val sellerOut = TxOut( address = Address.fromPubKeyHash(seller), value = Value.lovelace(bid), datum = OutputDatum.OutputDatum(hashA.toData) ) val winnerOut = TxOut( address = Address.fromPubKeyHash(buyer), value = Value.lovelace(2_000_000) + Value(hashA, itemIdA, 1) + Value(hashB, itemIdB, 1) ) val txInfo = TxInfo.placeholder.copy( inputs = SList(inputA, inputB), outputs = SList(sellerOut, winnerOut), validRange = Interval.after(endTime + 1), id = random[TxId] ) // Auction A is ended pointing at the shared seller output (idx 0) and winner output (idx 1). val ctxA = ScriptContext( txInfo, Action.End(BigInt(0), BigInt(0), BigInt(1)).toData, SpendingScript(outRefA) ) // Auction B points at the SAME seller output (idx 0) — the double-satisfaction attempt. val ctxB = ScriptContext( txInfo, Action.End(BigInt(1), BigInt(0), BigInt(1)).toData, SpendingScript(outRefB) ) val resultA = instA.program.runWithDebug(ctxA) val resultB = instB.program.runWithDebug(ctxB) // A is satisfied (the shared output carries A's tag), but B must reject it — the seller // output is not tagged with B's id, so the two auctions cannot share one seller payout. assert(resultA.isSuccess, s"Auction A should accept its own tagged output: ${resultA.logs}") assert( resultB.isFailure, s"Auction B must reject the shared seller output (double satisfaction): ${resultB.logs}" ) assert( resultB.logs.exists(_.contains("tagged with this auction")), s"Expected the id-tag error, got: ${resultB.logs.mkString(", ")}" ) } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/auction/DoubleSatisfactionAttackTest.scala ```scala package scalus.examples.auction import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.ByteString import scalus.uplc.builtin.ByteString.* import scalus.uplc.builtin.Data.toData import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.{Context, PlutusScriptsTransactionMutator} import scalus.cardano.node.Emulator import scalus.cardano.txbuilder.{RedeemerPurpose, TxBuilder} import scalus.compiler.Options import scalus.cardano.onchain.plutus.v1.{PosixTime, PubKeyHash} import scalus.cardano.onchain.plutus.prelude.Option as ScalusOption import scalus.testing.kit.TestUtil.{genesisHash, getScriptContextV3} import scalus.testing.kit.{ScalusTest, TestUtil} import scalus.testing.kit.Party.{Alice, Bob} import scalus.uplc.PlutusV3 import scalus.utils.await import java.time.Instant import scala.concurrent.ExecutionContext.Implicits.global /** Test demonstrating the Double Satisfaction vulnerability (V005). * * Attack: Bob wins two auctions from Alice, ends both in one tx paying Alice only once. */ class DoubleSatisfactionAttackTest extends AnyFunSuite, ScalusTest { private given env: CardanoInfo = TestUtil.testEnvironment private given Options = Options.release.copy(generateErrorTraces = true) private val vulnerableContract = PlutusV3.compile(UnfixedAuctionValidator.validate) private val fixedContract = AuctionContract.compiled.withErrorTraces private val seller = Alice private val buyer = Bob private val sellerPkh = PubKeyHash(ByteString.fromArray(seller.addrKeyHash.bytes)) private val buyerPkh = PubKeyHash(ByteString.fromArray(buyer.addrKeyHash.bytes)) private val itemIdA = utf8"item-A" private val itemIdB = utf8"item-B" private val bidAmount = 10_000_000L private val auctionEndTime: PosixTime = BigInt(env.slotConfig.slotToTime(100)) test("VULNERABILITY: Double satisfaction attack SUCCEEDS on UnfixedAuctionValidator") { val script = vulnerableContract.script val policyId = script.scriptHash val scriptAddress = vulnerableContract.address(env.network) // Create two won auction UTxOs val datumA = Datum( sellerPkh, ScalusOption.Some(buyerPkh), BigInt(bidAmount), auctionEndTime, itemIdA ) val datumB = Datum( sellerPkh, ScalusOption.Some(buyerPkh), BigInt(bidAmount), auctionEndTime, itemIdB ) val auctionUtxoA = Utxo( Input(genesisHash, 10), TransactionOutput.Babbage( address = scriptAddress, value = Value .lovelace(bidAmount + 2_000_000L) + Value.asset(policyId, AssetName(itemIdA), 1L), datumOption = Some(DatumOption.Inline(datumA.toData)) ) ) val auctionUtxoB = Utxo( Input(genesisHash, 11), TransactionOutput.Babbage( address = scriptAddress, value = Value .lovelace(bidAmount + 2_000_000L) + Value.asset(policyId, AssetName(itemIdB), 1L), datumOption = Some(DatumOption.Inline(datumB.toData)) ) ) val provider = Emulator( initialUtxos = Map( auctionUtxoA.input -> auctionUtxoA.output, auctionUtxoB.input -> auctionUtxoB.output, Input(genesisHash, 0) -> TransactionOutput .Babbage(buyer.address, Value.lovelace(100_000_000L)) ), initialContext = Context.testMainnet(slot = 200), mutators = Set(PlutusScriptsTransactionMutator) ) // Build ATTACK transaction: spend both auctions, pay seller only ONCE val tx = TxBuilder(env) .spend( auctionUtxoA, redeemerBuilder = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(auctionUtxoA.input) val sellerIdx = tx.body.value.outputs.indexWhere(_.value.address == seller.address) val winnerIdx = tx.body.value.outputs.indexWhere(_.value.address == buyer.address) Action.End(BigInt(inputIdx), BigInt(sellerIdx), BigInt(winnerIdx)).toData }, script ) .spend( auctionUtxoB, redeemerBuilder = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(auctionUtxoB.input) // ATTACK: Use SAME output indices as auction A! val sellerIdx = tx.body.value.outputs.indexWhere(_.value.address == seller.address) val winnerIdx = tx.body.value.outputs.indexWhere(_.value.address == buyer.address) Action.End(BigInt(inputIdx), BigInt(sellerIdx), BigInt(winnerIdx)).toData }, script ) // Pay seller only ONCE (should be twice!) .payTo(seller.address, Value.lovelace(bidAmount)) // Winner gets BOTH NFTs in one output .payTo( buyer.address, Value.lovelace(2_000_000L) + Value.asset(policyId, AssetName(itemIdA), 1L) + Value.asset(policyId, AssetName(itemIdB), 1L) ) .validFrom(Instant.ofEpochMilli(auctionEndTime.toLong + 1000)) .complete(provider, buyer.address) .map(_.sign(buyer.signer).transaction) .await() // Get UTxOs for script context creation val utxos = Map( auctionUtxoA.input -> auctionUtxoA.output, auctionUtxoB.input -> auctionUtxoB.output ) // Run validator for auction A val scriptContextA = tx.getScriptContextV3(utxos, RedeemerPurpose.ForSpend(auctionUtxoA.input)) val resultA = vulnerableContract.program.runWithDebug(scriptContextA) assert( resultA.isSuccess, s"Vulnerable validator A should pass attack: ${resultA.logs.mkString(", ")}" ) // Run validator for auction B - this is the DOUBLE SATISFACTION val scriptContextB = tx.getScriptContextV3(utxos, RedeemerPurpose.ForSpend(auctionUtxoB.input)) val resultB = vulnerableContract.program.runWithDebug(scriptContextB) assert( resultB.isSuccess, s"Vulnerable validator B should pass attack: ${resultB.logs.mkString(", ")}" ) } test("FIX VERIFICATION: Double satisfaction attack FAILS on fixed AuctionValidator") { val script = fixedContract.script val policyId = script.scriptHash val scriptAddress = fixedContract.address(env.network) val datumA = Datum( sellerPkh, ScalusOption.Some(buyerPkh), BigInt(bidAmount), auctionEndTime, itemIdA ) val datumB = Datum( sellerPkh, ScalusOption.Some(buyerPkh), BigInt(bidAmount), auctionEndTime, itemIdB ) val auctionUtxoA = Utxo( Input(genesisHash, 10), TransactionOutput.Babbage( address = scriptAddress, value = Value .lovelace(bidAmount + 2_000_000L) + Value.asset(policyId, AssetName(itemIdA), 1L), datumOption = Some(DatumOption.Inline(datumA.toData)) ) ) val auctionUtxoB = Utxo( Input(genesisHash, 11), TransactionOutput.Babbage( address = scriptAddress, value = Value .lovelace(bidAmount + 2_000_000L) + Value.asset(policyId, AssetName(itemIdB), 1L), datumOption = Some(DatumOption.Inline(datumB.toData)) ) ) val provider = Emulator( initialUtxos = Map( auctionUtxoA.input -> auctionUtxoA.output, auctionUtxoB.input -> auctionUtxoB.output, Input(genesisHash, 0) -> TransactionOutput .Babbage(buyer.address, Value.lovelace(100_000_000L)) ), initialContext = Context.testMainnet(slot = 200), mutators = Set(PlutusScriptsTransactionMutator) ) // Try to build attack transaction - fixed validator should reject during complete() val txResult = scala.util.Try { TxBuilder(env) .spend( auctionUtxoA, redeemerBuilder = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(auctionUtxoA.input) val sellerIdx = tx.body.value.outputs.indexWhere(_.value.address == seller.address) val winnerIdx = tx.body.value.outputs.indexWhere(_.value.address == buyer.address) Action.End(BigInt(inputIdx), BigInt(sellerIdx), BigInt(winnerIdx)).toData }, script ) .spend( auctionUtxoB, redeemerBuilder = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(auctionUtxoB.input) val sellerIdx = tx.body.value.outputs.indexWhere(_.value.address == seller.address) val winnerIdx = tx.body.value.outputs.indexWhere(_.value.address == buyer.address) Action.End(BigInt(inputIdx), BigInt(sellerIdx), BigInt(winnerIdx)).toData }, script ) .payTo(seller.address, Value.lovelace(bidAmount)) .payTo( buyer.address, Value.lovelace(2_000_000L) + Value.asset(policyId, AssetName(itemIdA), 1L) + Value.asset(policyId, AssetName(itemIdB), 1L) ) .validFrom(Instant.ofEpochMilli(auctionEndTime.toLong + 1000)) .complete(provider, buyer.address) .await() } // The fixed validator should reject the attack during script evaluation in complete() assert( txResult.isFailure, "Fixed validator should reject double satisfaction attack during transaction building" ) // Verify it's a script evaluation failure (not some other error) val errorMessage = txResult.failed.get.getMessage assert( errorMessage.contains("script") || errorMessage.contains("Error evaluated"), s"Expected script evaluation failure, but got: $errorMessage" ) } } ``` # Example: betting ## scalus-examples/jvm/src/main/scala/scalus/examples/betting/BettingContract.scala ```scala package scalus.examples.betting import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 object BettingContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(BettingValidator.validate) lazy val blueprint = Blueprint.plutusV3[Config, Action]( title = "Betting validator", description = "Decentralized two-player betting system with trustless wagering and oracle-based resolution", version = "1.0.0", license = None, compiled = compiled ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/betting/BettingTransactions.scala ```scala package scalus.examples.betting import scalus.uplc.builtin.ByteString.{hex, utf8} import scalus.uplc.builtin.Data import scalus.cardano.address.{Address, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart} import scalus.cardano.ledger.* import scalus.cardano.txbuilder.{TransactionSigner, TxBuilder} import scalus.cardano.onchain.plutus.v1.{PosixTime, PubKeyHash} import scalus.uplc.PlutusV3 case class BettingTransactions( env: CardanoInfo, evaluator: PlutusScriptEvaluator, signer: TransactionSigner, contract: PlutusV3[Data => Unit] ) { def script: Script.PlutusV3 = contract.script val scriptAddress: Address = contract.address(env.network) def deploy( utxos: Utxos, deploymentAddress: Address, changeAddress: Address ): Transaction = { TxBuilder(env, evaluator) .spend(utxos) .output( Output( deploymentAddress, Value.zero, None, Some(ScriptRef(script)) ) ) .build(changeTo = changeAddress) .sign(signer) .transaction } def init( utxos: Utxos, collateralUtxo: Utxo, scriptUtxo: Utxo, bet: Coin, player1: PubKeyHash, oracle: PubKeyHash, expiration: PosixTime, changeAddress: Address, beforeTime: Long, player2: PubKeyHash = PubKeyHash(hex""), token: AssetName = AssetName(utf8"lucky_number_slevin"), amount: Long = 1L ): Transaction = { val config = Config(player1, player2, oracle, expiration) val player1Pkh = AddrKeyHash.fromByteString(player1.hash) TxBuilder(env, evaluator) .spend(utxos) .collaterals(collateralUtxo) .references(scriptUtxo) .mint( script.scriptHash, scala.collection.Map(token -> amount), Data.unit ) .requireSignature(player1Pkh) .payTo( scriptAddress, Value.asset(script.scriptHash, token, amount, bet), config ) .validTo(java.time.Instant.ofEpochMilli(beforeTime)) .build(changeTo = changeAddress) .sign(signer) .transaction } def join( utxos: Utxos, collateralUtxo: Utxo, scriptUtxo: Utxo, betUtxo: Utxo, bet: Coin, player1: PubKeyHash, player2: PubKeyHash, player2Pkh: AddrKeyHash, oracle: PubKeyHash, expiration: PosixTime, changeAddress: Address, beforeTime: Long ): Transaction = { val lovelace = Value(bet) val config = Config(player1, player2, oracle, expiration) TxBuilder(env, evaluator) .spend(utxos) .collaterals(collateralUtxo) .references(scriptUtxo) .spend(betUtxo, Action.Join) .requireSignature(player2Pkh) .payTo(scriptAddress, betUtxo.output.value + lovelace, config) .validTo(java.time.Instant.ofEpochMilli(beforeTime)) .build(changeTo = changeAddress) .sign(signer) .transaction } def win( utxos: Utxos, collateralUtxo: Utxo, scriptUtxo: Utxo, betUtxo: Utxo, isJoinWin: Boolean, player1: PubKeyHash, player2: PubKeyHash, oracle: PubKeyHash, oraclePkh: AddrKeyHash, changeAddress: Address, afterTime: Long ): Transaction = { val payout = if isJoinWin then player2 else player1 val payoutAddress = ShelleyAddress( network = env.network, payment = ShelleyPaymentPart.Key(AddrKeyHash.fromByteString(payout.hash)), delegation = ShelleyDelegationPart.Null ) // Payout output will be at index 0 (first output added) val payoutOutputIdx = BigInt(0) TxBuilder(env, evaluator) .spend(utxos) .collaterals(collateralUtxo) .references(scriptUtxo) .spend(betUtxo, Action.AnnounceWinner(payout, payoutOutputIdx)) // Burn the bet NFT so it can't be re-locked; the winner gets only the pot lovelace. .mint(script.scriptHash, scala.collection.Map(betToken(betUtxo) -> -1L), Data.unit) .requireSignature(oraclePkh) .payTo(payoutAddress, Value(betUtxo.output.value.coin)) .validFrom(java.time.Instant.ofEpochMilli(afterTime)) .build(changeTo = changeAddress) .sign(signer) .transaction } /** The bet NFT's asset name — the single token under this script's policy in the bet UTxO. */ private def betToken(utxo: Utxo): AssetName = utxo.output.value.assets.assets .get(script.scriptHash) .flatMap(_.keys.headOption) .getOrElse(throw IllegalStateException("Bet UTxO must hold a bet token")) /** Reclaim the bet after expiration when the oracle never announced a winner. * * If `player2` has joined, the doubled pot is split back to both players (the beacon token * rides with player1's output); otherwise the whole pot is refunded to player1. A player must * sign (`signerPkh`). */ def timeout( utxos: Utxos, collateralUtxo: Utxo, scriptUtxo: Utxo, betUtxo: Utxo, bet: Coin, player1: PubKeyHash, player2: PubKeyHash, signerPkh: AddrKeyHash, changeAddress: Address, afterTime: Long ): Transaction = { def enterprise(pkh: PubKeyHash): Address = ShelleyAddress( network = env.network, payment = ShelleyPaymentPart.Key(AddrKeyHash.fromByteString(pkh.hash)), delegation = ShelleyDelegationPart.Null ) val potLovelace = Value(betUtxo.output.value.coin) val base = TxBuilder(env, evaluator) .spend(utxos) .collaterals(collateralUtxo) .references(scriptUtxo) .spend(betUtxo, Action.Timeout) // Burn the bet NFT so a reclaimed bet's token can't be re-locked; refund lovelace only. .mint(script.scriptHash, scala.collection.Map(betToken(betUtxo) -> -1L), Data.unit) .requireSignature(signerPkh) .validFrom(java.time.Instant.ofEpochMilli(afterTime)) val withPayouts = if player2.hash == hex"" then base.payTo(enterprise(player1), potLovelace) else base // Each player gets their stake back; the NFT is burned, not handed out. .payTo(enterprise(player1), Value(bet)) .payTo(enterprise(player2), Value(bet)) withPayouts .build(changeTo = changeAddress) .sign(signer) .transaction } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/betting/BettingValidator.scala ```scala package scalus.examples.betting import scalus.compiler.Compile import scalus.uplc.builtin.ByteString.* import scalus.uplc.builtin.Data import scalus.uplc.builtin.Data.FromData import scalus.uplc.builtin.Data.ToData import scalus.uplc.builtin.ToData.* import scalus.cardano.onchain.plutus.v1.Address import scalus.cardano.onchain.plutus.v1.Credential.ScriptCredential import scalus.cardano.onchain.plutus.v2.OutputDatum.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.v3.Validator // Datum /** Represents the state of a two-player betting game The bet starts with player1 creating it, then * player2 can join The oracle decides the winner and triggers the payout. * * @param player1 * The public key hash of the first player (bet creator) * @param player2 * The public key hash of the second player (None if no one has joined yet) * @param oracle * The public key hash of the trusted oracle who will announce the winner * @param expiration * The expiration time of the bet (in seconds since the epoch) */ case class Config( player1: PubKeyHash, player2: PubKeyHash, oracle: PubKeyHash, expiration: PosixTime ) derives FromData, ToData // Redeemer /** Actions that can be performed on the betting contract */ enum Action derives FromData, ToData: /** Action for player2 to join an existing bet */ case Join /** Action for the oracle to announce the winner and trigger payout * @param winner * The winner's public key hash (must be player1 or player2) * @param payoutOutputIdx * Index of the payout output in tx.outputs (V005 fix: prevents double satisfaction) */ case AnnounceWinner(winner: PubKeyHash, payoutOutputIdx: BigInt) /** Action for a player to reclaim the bet after expiration when the oracle never announced a * winner. Without this the funds would lock forever if the oracle goes silent (or nobody * joined). */ case Timeout /** Main betting validator * @see * [[https://github.com/cardano-foundation/cardano-template-and-ecosystem-monitoring/blob/main/bet/onchain/aiken/validators/bet.ak Bet]] * @note * [[https://github.com/cardano-foundation/cardano-template-and-ecosystem-monitoring/issues/15 known issue]] */ @Compile object BettingValidator extends Validator { /** Spending validator: Controls how the bet UTXO can be spent Handles both * [[scalus.examples.betting.Action.Join]] and * [[scalus.examples.betting.Action.AnnounceWinner]] actions */ inline override def spend( @annotation.unused datum: Option[Data], redeemer: Data, txInfo: TxInfo, txOutRef: TxOutRef ): Unit = val (scriptHash, address, value, Config(player1, player2, oracle, expiration)) = txInfo.findOwnInputOrFail(txOutRef, "Bet spent input must be present") match case TxInInfo( _, TxOut( address @ Address(Credential.ScriptCredential(scriptHash), _), value, OutputDatum(currentDatum), _ ) ) => (scriptHash, address, value, currentDatum.to[Config]) case _ => fail("Initial bet datum must be inline") redeemer.to[Action] match case Action.Join => val ( outputAddress, outputValue, Config(newPlayer1, joiningPlayer, newOracle, newExpiration) ) = txInfo .findOwnScriptOutputs(scriptHash) .match case List.Cons( TxOut(outAddr, outValue, OutputDatum(newDatum), _), List.Nil ) => (outAddr, outValue, newDatum.to[Config]) case _ => fail( "There must be a single continuing spent output with inline new betting config that goes to the script" ) require( player2.hash.length === BigInt(0), "Current bet must not have a player2 yet" ) require( value.policyIds.contains(scriptHash), "Input must contain the bet token" ) // V002 fix: Verify bet token is preserved in output require( outputValue.policyIds.contains(scriptHash), "Output must contain the bet token" ) // V016 fix: Verify full address including staking credential require( outputAddress === address, "Output address must match input address (including staking credential)" ) require( txInfo.isSignedBy(joiningPlayer), "Player2 must sign the transaction" ) require( newOracle === oracle, "Oracle must remain unchanged" ) require( newPlayer1 === player1, "Player1 must remain unchanged" ) require( joiningPlayer !== player1, "Player2 cannot be the same as player1" ) require( joiningPlayer !== oracle, "Player2 cannot be the same as oracle" ) require( outputValue.getLovelace === BigInt(2) * value.getLovelace, "The bet amount must double (player2 matches player1's bet)" ) require( newExpiration === expiration, "The updated betting config must have the same expiration as the current one" ) require( txInfo.validRange.isEntirelyBefore(newExpiration), "Joining must happen before the bet expiration" ) case Action.AnnounceWinner(winner, payoutOutputIdx) => // V005 fix: Use indexed lookup to prevent double satisfaction require( payoutOutputIdx >= BigInt(0), "Payout output index must be non-negative" ) val payoutOutput = txInfo.outputs.at(payoutOutputIdx) val TxOut(payoutAddress, payoutValue, _, _) = payoutOutput require( winner === player1 || winner === player2, "Winner must be either player1 or player2" ) require( player2.hash.length != BigInt(0), "Both players must have joined (player2 is not None)" ) require( payoutAddress === Address.fromPubKeyHash(winner), "Payout goes to the winner's address" ) // V005 fix: Verify payout contains at least this bet's value require( payoutValue.getLovelace >= value.getLovelace, "Payout must contain at least the bet amount" ) require( txInfo.isSignedBy(oracle), "Oracle must sign the transaction" ) require( txInfo.validRange.isEntirelyAfter(expiration), "The bet must have been expired (no future bets allowed) before announcing" ) // Burn the bet NFT so the bet is one-shot and cannot be re-locked into a forged bet. require( txInfo.mint.quantityOf(scriptHash, betTokenName(value, scriptHash)) === BigInt( -1 ), "The bet token must be burned when announcing the winner" ) case Action.Timeout => // Reclaim is only possible once the bet has expired without a winner announced. require( txInfo.validRange.isEntirelyAfter(expiration), "Cannot reclaim before the bet has expired" ) // A player must initiate the reclaim. require( txInfo.isSignedBy(player1) || txInfo.isSignedBy(player2), "Reclaim must be signed by one of the players" ) // Exactly one bet input — the per-player refund check below sums outputs by address, // so batching two bets in one tx could let one refund satisfy both. One input per // reclaim keeps the accounting sound. require( txInfo.findOwnInputsByCredential(address.credential).length === BigInt(1), "Reclaim must spend exactly one bet input" ) // Burn the bet NFT so a reclaimed bet's token can't be re-locked into a forged bet. require( txInfo.mint.quantityOf(scriptHash, betTokenName(value, scriptHash)) === BigInt( -1 ), "The bet token must be burned on timeout" ) if player2.hash.isEmpty then // No opponent joined — refund the whole pot to player1. require( totalPaidTo(txInfo, player1) >= value.getLovelace, "Player1 must be refunded the full bet on timeout" ) else // Both players staked — return each their half of the doubled pot. val stake = value.getLovelace / BigInt(2) require( totalPaidTo(txInfo, player1) >= stake, "Player1 must be refunded their stake on timeout" ) require( totalPaidTo(txInfo, player2) >= stake, "Player2 must be refunded their stake on timeout" ) /** The bet NFT's token name — the single asset under the bet's own policy in its UTxO value. */ private inline def betTokenName(value: Value, scriptHash: PolicyId): TokenName = value.tokens(scriptHash).toList match case List.Cons((name, _), List.Nil) => name case _ => fail("Bet UTxO must hold exactly one bet token") /** Sum the lovelace paid to a public key's (enterprise) address across all outputs. */ private inline def totalPaidTo(txInfo: TxInfo, pkh: PubKeyHash): BigInt = txInfo.outputs.foldLeft(BigInt(0)) { (acc, out) => if out.address === Address.fromPubKeyHash(pkh) then acc + out.value.getLovelace else acc } /** Minting policy: * * Controls the creation of bet tokens This ensures proper initialization of a new bet */ inline override def mint( @annotation.unused redeemer: Data, policyId: PolicyId, tx: TxInfo ): Unit = // Exactly one token type under this policy, either minted (+1, a new bet) or burned (-1, a // bet ending). (V003/V011 fix.) val quantity = tx.mint.tokens(policyId).toList match case List.Cons((_, qty), List.Nil) => qty case _ => fail("Must mint or burn exactly one token type under this policy") if quantity === BigInt(-1) then // Burning the bet NFT at the end of a bet. The token can only ever sit in a bet UTxO at // the script address, so consuming it to burn necessarily runs the spending validator // (AnnounceWinner / Timeout), which authorizes the end. Allowing the burn here is what // makes the token a true one-shot: a finished bet's NFT is destroyed, so it can never be // re-locked at the script with a forged config to bypass the initialization checks. () else require(quantity === BigInt(1), "Must mint exactly one token") val Config(player1, player2, oracle, expiration) = tx.outputs .filter: _.address === Address.fromScriptHash(policyId) .match case List.Cons(TxOut(_, _, OutputDatum(datum), _), List.Nil) => datum.to[Config] case _ => fail( "There must be a single output with inline initial betting config that goes to the script" ) require( tx.isSignedBy(player1), "Player1 must sign the transaction (they're creating the bet)" ) require( player2.hash.isEmpty, "Player2 must be empty (no one has joined yet)" ) require( oracle !== player1, "Oracle cannot be the same as player1 (conflict of interest)" ) require( tx.validRange.isEntirelyBefore(expiration), "The bet must have a valid expiration time (after the current time)" ) } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/betting/BettingTransactionTest.scala ```scala package scalus.examples.betting import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.ByteString.hex import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.platform import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.* import scalus.cardano.ledger.utils.AllResolvedScripts import scalus.cardano.node.{Emulator, UtxoFilter, UtxoQuery, UtxoSource} import scalus.cardano.txbuilder.{RedeemerPurpose, TransactionSigner} import scalus.cardano.onchain.plutus.v1.{PosixTime, PubKeyHash} import scalus.testing.kit.TestUtil.{genesisHash, getScriptContextV3} import scalus.testing.kit.{ScalusTest, TestUtil} import scalus.uplc.eval.Result import scalus.utils.await class BettingTransactionTest extends AnyFunSuite, ScalusTest { private given env: CardanoInfo = TestUtil.testEnvironment private val contract = BettingContract.compiled.withErrorTraces private val scriptAddress = contract.address(env.network) // Generate real key pairs for all participants private val player1KeyPair @ (player1PrivateKey, player1PublicKey) = generateKeyPair() private val player2KeyPair @ (player2PrivateKey, player2PublicKey) = generateKeyPair() private val oracleKeyPair @ (oraclePrivateKey, oraclePublicKey) = generateKeyPair() private val deploymentKeyPair @ (deploymentPrivateKey, deploymentPublicKey) = generateKeyPair() // Create signers private val player1Signer = TransactionSigner(Set(player1KeyPair)) private val player2Signer = TransactionSigner(Set(player2KeyPair)) private val oracleSigner = TransactionSigner(Set(oracleKeyPair)) private val deploymentSigner = TransactionSigner(Set(deploymentKeyPair)) // Derive public key hashes and addresses private val player1Pkh = AddrKeyHash(platform.blake2b_224(player1PublicKey)) private val player2Pkh = AddrKeyHash(platform.blake2b_224(player2PublicKey)) private val oraclePkh = AddrKeyHash(platform.blake2b_224(oraclePublicKey)) private val deploymentPkh = AddrKeyHash(platform.blake2b_224(deploymentPublicKey)) private val player1Address = TestUtil.createTestAddress(player1Pkh) private val player2Address = TestUtil.createTestAddress(player2Pkh) private val oracleAddress = TestUtil.createTestAddress(oraclePkh) private val deploymentAddress = TestUtil.createTestAddress(deploymentPkh) // Transaction creator factories private def transactionCreatorFor(signer: TransactionSigner) = BettingTransactions( env = env, evaluator = PlutusScriptEvaluator(env, EvaluatorMode.EvaluateAndComputeCost), signer = signer, contract = contract ) private def transactionCreatorWithConstEvaluatorFor(signer: TransactionSigner) = BettingTransactions( env = env, evaluator = PlutusScriptEvaluator.constMaxBudget(env), signer = signer, contract = contract ) // Test parameters private val betAmount = Coin(10_000_000L) private val commissionAmount = Coin(2_000_000L) private val slot: SlotNo = 3L private val beforeSlot: SlotNo = 2L private val afterSlot: SlotNo = 4L private val expiration: PosixTime = BigInt(env.slotConfig.slotToTime(slot)) private val beforeTime: Long = env.slotConfig.slotToTime(beforeSlot) private val afterTime: Long = env.slotConfig.slotToTime(afterSlot) // Provider factory private def createProvider(): Emulator = { Emulator( initialUtxos = Map( Input(genesisHash, 0) -> Output( address = player1Address, value = Value.ada(1_000_000L) ), Input(genesisHash, 1) -> Output( address = player2Address, value = Value.ada(1_000_000L) ), Input(genesisHash, 2) -> Output( address = oracleAddress, value = Value.ada(1_000_000_000L) ), Input(genesisHash, 3) -> Output( address = deploymentAddress, value = Value.ada(1_000_000_000L) ) ), initialContext = Context.testMainnet(), mutators = Set(PlutusScriptsTransactionMutator) ) } private def runValidator( provider: Emulator, tx: Transaction, scriptInput: TransactionInput ): Result = { val utxos = { val body = tx.body.value val allInputs = (body.inputs.toSet.view ++ body.collateralInputs.toSet.view ++ body.referenceInputs.toSet.view).toSet provider.findUtxos(allInputs).await().toOption.get } val scriptContext = tx.getScriptContextV3(utxos, RedeemerPurpose.ForSpend(scriptInput)) val allResolvedPlutusScriptsMap = AllResolvedScripts.allResolvedPlutusScriptsMap(tx, utxos).toOption.get val plutusScript = scriptAddress.scriptHashOption.flatMap(allResolvedPlutusScriptsMap.get).get val program = plutusScript.deBruijnedProgram.toProgram program.runWithDebug(scriptContext) } private def deployScript(provider: Emulator) = { val deployTx = { val utxos = provider .queryUtxos(u => u.output.address == deploymentAddress) .minTotal(commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(deploymentSigner) .deploy(utxos, deploymentAddress, deploymentAddress) } assert(provider.submit(deployTx).await().isRight) val scriptUtxos = provider .queryUtxos(u => u.output.address == deploymentAddress && u.input.transactionId == deployTx.id ) .execute() .await() .getOrElse(fail("No UTXOs found at deployment address")) Utxo(scriptUtxos.find((in, out) => out.scriptRef.isDefined).get) } test("deploy script") { val provider = createProvider() val scriptUtxo = deployScript(provider) assert(scriptUtxo.output.scriptRef.isDefined) } test("init bet") { val provider = createProvider() val scriptUtxo = deployScript(provider) val initTx = { val utxos = provider .queryUtxos(u => u.output.address == player1Address) .minTotal(betAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(player1Signer) .init( utxos, Utxo(utxos.head), scriptUtxo, betAmount, PubKeyHash(player1Pkh), PubKeyHash(oraclePkh), expiration, player1Address, beforeTime ) } assert(provider.submit(initTx).await().isRight) val initConfig = Config( PubKeyHash(player1Pkh), PubKeyHash(hex""), PubKeyHash(oraclePkh), expiration ) val betUtxo = provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == initTx.id && u.output.hasDatumHash(DatumOption.Inline(initConfig.toData).dataHash) && u.output.value.coin >= betAmount } .execute() .await() .toOption .get .headOption .map(Utxo.apply) .get assert(betUtxo.output.value.coin == betAmount) } test("player2 joins bet before expiration") { val provider = createProvider() val scriptUtxo = deployScript(provider) val initTx = { val utxos = provider .queryUtxos(u => u.output.address == player1Address) .minTotal(betAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(player1Signer) .init( utxos, Utxo(utxos.head), scriptUtxo, betAmount, PubKeyHash(player1Pkh), PubKeyHash(oraclePkh), expiration, player1Address, beforeTime ) } assert(provider.submit(initTx).await().isRight) val initConfig = Config( PubKeyHash(player1Pkh), PubKeyHash(hex""), PubKeyHash(oraclePkh), expiration ) val betUtxo = provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == initTx.id && u.output.hasDatumHash(DatumOption.Inline(initConfig.toData).dataHash) && u.output.value.coin >= betAmount } .execute() .await() .toOption .get .headOption .map(Utxo.apply) .get val joinTx = { val utxos = provider .queryUtxos(u => u.output.address == player2Address) .minTotal(betAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(player2Signer) .join( utxos, Utxo(utxos.head), scriptUtxo, betUtxo, betAmount, PubKeyHash(player1Pkh), PubKeyHash(player2Pkh), player2Pkh, PubKeyHash(oraclePkh), expiration, player2Address, beforeTime ) } val result = runValidator(provider, joinTx, betUtxo.input) assert(result.isSuccess) assert( result.budget == (ExUnits(memory = 212729, steps = 71_334352)) ) provider.setSlot(beforeSlot - 1) assert(provider.submit(joinTx).await().isRight) val joinConfig = Config( PubKeyHash(player1Pkh), PubKeyHash(player2Pkh), PubKeyHash(oraclePkh), expiration ) val joinedBetUtxo = Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == joinTx.id && u.output.hasDatumHash(DatumOption.Inline(joinConfig.toData).dataHash) && u.output.value.coin >= betAmount + betAmount } .execute() .await() .toOption .get .head ) assert(joinedBetUtxo.output.value.coin == betAmount + betAmount) } test("player2 joining fails after expiration") { val provider = createProvider() val scriptUtxo = deployScript(provider) val initTx = { val utxos = provider .queryUtxos(u => u.output.address == player1Address) .minTotal(betAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(player1Signer) .init( utxos, Utxo(utxos.head), scriptUtxo, betAmount, PubKeyHash(player1Pkh), PubKeyHash(oraclePkh), expiration, player1Address, beforeTime ) } assert(provider.submit(initTx).await().isRight) val initConfig = Config( PubKeyHash(player1Pkh), PubKeyHash(hex""), PubKeyHash(oraclePkh), expiration ) val betUtxo = provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == initTx.id && u.output.hasDatumHash(DatumOption.Inline(initConfig.toData).dataHash) && u.output.value.coin >= betAmount } .execute() .await() .toOption .get .headOption .map(Utxo.apply) .get provider.setSlot(env.slotConfig.timeToSlot(expiration.toLong)) val joinTx = { val utxos = provider .queryUtxos(u => u.output.address == player2Address) .minTotal(betAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorWithConstEvaluatorFor(player2Signer) .join( utxos, Utxo(utxos.head), scriptUtxo, betUtxo, betAmount, PubKeyHash(player1Pkh), PubKeyHash(player2Pkh), player2Pkh, PubKeyHash(oraclePkh), expiration, player2Address, beforeTime ) } provider.submit(joinTx).await() match case Left(err) => succeed // Expected to fail case Right(_) => fail("Transaction should have failed after expiration") } test("oracle announces winner after expiration") { val provider = createProvider() val scriptUtxo = deployScript(provider) val initTx = { val utxos = provider .queryUtxos(u => u.output.address == player1Address) .minTotal(betAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(player1Signer) .init( utxos, Utxo(utxos.head), scriptUtxo, betAmount, PubKeyHash(player1Pkh), PubKeyHash(oraclePkh), expiration, player1Address, beforeTime ) } assert(provider.submit(initTx).await().isRight) val betUtxo = Utxo( provider .queryUtxos(u => u.output.address == scriptAddress && u.input.transactionId == initTx.id ) .execute() .await() .toOption .get .head ) val joinTx = { val utxos = provider .queryUtxos(u => u.output.address == player2Address) .minTotal(betAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(player2Signer) .join( utxos, Utxo(utxos.head), scriptUtxo, betUtxo, betAmount, PubKeyHash(player1Pkh), PubKeyHash(player2Pkh), player2Pkh, PubKeyHash(oraclePkh), expiration, player2Address, beforeTime ) } provider.setSlot(beforeSlot - 1) assert(provider.submit(joinTx).await().isRight) val joinedBetUtxo = Utxo( provider .queryUtxos(u => u.output.address == scriptAddress && u.input.transactionId == joinTx.id ) .execute() .await() .toOption .get .head ) val winTx = { val utxos = provider .queryUtxos(u => u.output.address == oracleAddress) .minTotal(commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(oracleSigner) .win( utxos, Utxo(utxos.head), scriptUtxo, joinedBetUtxo, isJoinWin = true, PubKeyHash(player1Pkh), PubKeyHash(player2Pkh), PubKeyHash(oraclePkh), oraclePkh, oracleAddress, afterTime ) } val result = runValidator(provider, winTx, joinedBetUtxo.input) assert(result.isSuccess) assert( result.budget == (ExUnits(memory = 177743, steps = 54_419513)) ) provider.setSlot(env.slotConfig.timeToSlot(afterTime)) assert(provider.submit(winTx).await().isRight) val winnerUtxo = Utxo( provider .queryUtxos { u => u.output.address == player2Address && u.input.transactionId == winTx.id && u.output.value.coin >= betAmount + betAmount } .execute() .await() .toOption .get .head ) assert(winnerUtxo.output.value.coin == betAmount + betAmount) } test("players reclaim the pot after a timeout") { val provider = createProvider() val scriptUtxo = deployScript(provider) val initTx = { val utxos = provider .queryUtxos(u => u.output.address == player1Address) .minTotal(betAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(player1Signer) .init( utxos, Utxo(utxos.head), scriptUtxo, betAmount, PubKeyHash(player1Pkh), PubKeyHash(oraclePkh), expiration, player1Address, beforeTime ) } assert(provider.submit(initTx).await().isRight) val betUtxo = Utxo( provider .queryUtxos(u => u.output.address == scriptAddress && u.input.transactionId == initTx.id ) .execute() .await() .toOption .get .head ) val joinTx = { val utxos = provider .queryUtxos(u => u.output.address == player2Address) .minTotal(betAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(player2Signer) .join( utxos, Utxo(utxos.head), scriptUtxo, betUtxo, betAmount, PubKeyHash(player1Pkh), PubKeyHash(player2Pkh), player2Pkh, PubKeyHash(oraclePkh), expiration, player2Address, beforeTime ) } provider.setSlot(beforeSlot - 1) assert(provider.submit(joinTx).await().isRight) val joinedBetUtxo = Utxo( provider .queryUtxos(u => u.output.address == scriptAddress && u.input.transactionId == joinTx.id ) .execute() .await() .toOption .get .head ) // Oracle never announces; after expiration player1 reclaims, splitting the pot. val timeoutTx = { val utxos = provider .queryUtxos(u => u.output.address == player1Address) .minTotal(commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(player1Signer) .timeout( utxos, Utxo(utxos.head), scriptUtxo, joinedBetUtxo, betAmount, PubKeyHash(player1Pkh), PubKeyHash(player2Pkh), player1Pkh, player1Address, afterTime ) } val result = runValidator(provider, timeoutTx, joinedBetUtxo.input) assert(result.isSuccess) // No exact-ExUnits assertion here: this integration test uses random key pairs and coin // selection, so the balanced two-payout tx varies run to run. The deterministic Timeout // budget is pinned by the ScriptContext-level test in BettingValidatorTest. provider.setSlot(env.slotConfig.timeToSlot(afterTime)) assert(provider.submit(timeoutTx).await().isRight) // player2 is refunded their full stake val player2Refund = Utxo( provider .queryUtxos(u => u.output.address == player2Address && u.input.transactionId == timeoutTx.id ) .execute() .await() .toOption .get .head ) assert(player2Refund.output.value.coin == betAmount) } test("oracle announcing winner fails before expiration") { val provider = createProvider() val scriptUtxo = deployScript(provider) val initTx = { val utxos = provider .queryUtxos(u => u.output.address == player1Address) .minTotal(betAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(player1Signer) .init( utxos, Utxo(utxos.head), scriptUtxo, betAmount, PubKeyHash(player1Pkh), PubKeyHash(oraclePkh), expiration, player1Address, beforeTime ) } assert(provider.submit(initTx).await().isRight) val betUtxo = Utxo( provider .queryUtxos(u => u.output.address == scriptAddress && u.input.transactionId == initTx.id ) .execute() .await() .toOption .get .head ) val joinTx = { val utxos = provider .queryUtxos(u => u.output.address == player2Address) .minTotal(betAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(player2Signer) .join( utxos, Utxo(utxos.head), scriptUtxo, betUtxo, betAmount, PubKeyHash(player1Pkh), PubKeyHash(player2Pkh), player2Pkh, PubKeyHash(oraclePkh), expiration, player2Address, beforeTime ) } provider.setSlot(beforeSlot - 1) assert(provider.submit(joinTx).await().isRight) val joinedBetUtxo = Utxo( provider .queryUtxos(u => u.output.address == scriptAddress && u.input.transactionId == joinTx.id ) .execute() .await() .toOption .get .head ) val winTx = { val utxos = provider .queryUtxos(u => u.output.address == oracleAddress) .minTotal(commissionAmount) .execute() .await() .toOption .get transactionCreatorWithConstEvaluatorFor(oracleSigner) .win( utxos, Utxo(utxos.head), scriptUtxo, joinedBetUtxo, isJoinWin = true, PubKeyHash(player1Pkh), PubKeyHash(player2Pkh), PubKeyHash(oraclePkh), oraclePkh, oracleAddress, beforeTime // Try to announce winner before expiration ) } val result = runValidator(provider, winTx, joinedBetUtxo.input) assert(result.isFailure) provider.submit(winTx).await() match case Left(err) => succeed // Expected to fail case Right(_) => fail("Transaction should have failed before expiration") } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/betting/BettingValidatorTest.scala ```scala package scalus.examples.betting import org.scalatest.funsuite.AnyFunSuite import scalus.* import scalus.uplc.builtin.ByteString import scalus.uplc.builtin.ByteString.* import scalus.uplc.builtin.Data.toData import scalus.cardano.onchain.plutus.v1.Address import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.cardano.onchain.plutus.v1.PubKeyHash.* import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.prelude.Option.* import scalus.cardano.ledger.ExUnits import scalus.testing.kit.{ScalusTest, TestUtil} import scala.language.implicitConversions class BettingValidatorTest extends AnyFunSuite, ScalusTest: private val contract = BettingContract.compiled.withErrorTraces /* case class TestCase( expiration: PosixTime ): def check: Result = val testTransaction = TxInfo.placeholder val action = Nothing val result = BettingContract.compiled.runScript( ScriptContext( txInfo = testTransaction, redeemer = action.toData, scriptInfo = ScriptInfo.SpendingScript( txOutRef = tx ) ) ) println(result.logs) result */ test("Verify that a bet can be properly initialized"): val player1 = TestUtil.mockPubKeyHash(1) // Create test betting config for a new bet val initialBettingConfig = Config( player1, // No second player yet player2 = pkh"", oracle = TestUtil.mockPubKeyHash(3), // 31th of July 2025 expiration = 1753939940, ) val policyId = TestUtil.mockScriptHash(1) // Create test transaction that mints a bet token val testTransaction = TxInfo.placeholder.copy( outputs = List( TxOut( address = Address.fromScriptHash(policyId), // 3 ADA initial bet value = Value.lovelace(3_000_000) + Value( cs = policyId, tn = utf8"lucky_number_slevin", v = 1 ), datum = OutputDatum.OutputDatum(initialBettingConfig.toData) ) ), // Include the minted token in tx.mint mint = Value(policyId, utf8"lucky_number_slevin", 1), signatories = List(player1), // 20th of July 2025 - for 5 minutes validRange = Interval.between(1752989540, 1752990020) ) val result = contract.program.runWithDebug( ScriptContext( txInfo = testTransaction, scriptInfo = ScriptInfo.MintingScript(policyId = policyId) ) ) if result.isFailure then result.logs.foreach(println) println(result) assert(result.isSuccess, "Script execution should succeed for initial minting") assert( result.budget == (ExUnits(memory = 79231, steps = 24_911977)) ) test("Verify that player2 can join an existing bet"): val player1 = TestUtil.mockPubKeyHash(1) val player2 = TestUtil.mockPubKeyHash(2) val oracle = TestUtil.mockPubKeyHash(3) // Initial state: bet created by player1 val initialBettingConfig = Config( player1, // No second player yet player2 = PubKeyHash(ByteString.empty), oracle = oracle, // 31th of July 2025 expiration = 1753939940, ) // Updated state: player2 has joined val updatedBettingConfig = Config( player1, player2, oracle, // 31th of July 2025 expiration = 1753939940, ) val policyId = TestUtil.mockScriptHash(1) val tx = TestUtil.mockTxOutRef(1, 0) // Create test transaction where player2 joins val testTransaction = TxInfo.placeholder.copy( inputs = List( TxInInfo( outRef = tx, resolved = TxOut( address = Address.fromScriptHash(policyId), // Original 3 ADA bet value = Value.lovelace(3_000_000) + Value( cs = policyId, tn = utf8"lucky_number_slevin", v = 1 ), datum = OutputDatum.OutputDatum(initialBettingConfig.toData) ) ) ), outputs = List( TxOut( address = Address.fromScriptHash(policyId), // Doubled to 6 ADA value = Value.lovelace(6_000_000) + Value( cs = policyId, tn = utf8"lucky_number_slevin", v = 1 ), datum = OutputDatum.OutputDatum(updatedBettingConfig.toData) ) ), signatories = List(player2), // 22th of July 2025 - for 5 minutes validRange = Interval.between(1753162820, 1753163120) ) val joinAction: Action = Action.Join val result = contract.program.runWithDebug( ScriptContext( txInfo = testTransaction, redeemer = joinAction.toData, scriptInfo = ScriptInfo.SpendingScript( txOutRef = tx, datum = Some(updatedBettingConfig.toData) ) ) ) if result.isFailure then result.logs.foreach(println) println(result) assert(result.isSuccess, "Script execution should succeed for player2 joining spending") assert( result.budget == (ExUnits(memory = 202912, steps = 67_494456)) ) test("Verify that the oracle can announce winner and trigger payout"): val player1 = TestUtil.mockPubKeyHash(1) val player2 = TestUtil.mockPubKeyHash(2) val oracle = TestUtil.mockPubKeyHash(3) // Final bet state with both players val finalBettingConfig = Config( player1, player2, oracle, // 31th of July 2025 expiration = 1753939940, ) val policyId = TestUtil.mockScriptHash(1) val tx = TestUtil.mockTxOutRef(1, 0) // Create test transaction where oracle announces player2 as winner val testTransaction = TxInfo.placeholder.copy( inputs = List( TxInInfo( outRef = tx, resolved = TxOut( address = Address.fromScriptHash(policyId), // Total pot: 6 ADA value = Value.lovelace(6_000_000) + Value( cs = policyId, tn = utf8"lucky_number_slevin", v = 1 ), datum = OutputDatum.OutputDatum(finalBettingConfig.toData) ) ) ), outputs = List( TxOut( // Payout goes to player2's address — the pot lovelace; the NFT is burned, not paid out. address = Address.fromPubKeyHash(player2), value = Value.lovelace(6_000_000) ) ), // The bet NFT is burned on payout (one-shot). mint = Value(policyId, utf8"lucky_number_slevin", -1), // Oracle signs to announce the winner signatories = List(oracle), // 1st of August 2025 - for 5 minutes validRange = Interval.between(1754027120, 1754027420) ) val announceWinnerAction: Action = Action.AnnounceWinner(player2, BigInt(0)) val result = contract.program.runWithDebug( ScriptContext( txInfo = testTransaction, redeemer = announceWinnerAction.toData, scriptInfo = ScriptInfo.SpendingScript( txOutRef = tx ) ) ) if result.isFailure then result.logs.foreach(println) println(result) assert(result.isSuccess, "Script execution should succeed for announce winner spending") assert( result.budget == (ExUnits(memory = 174246, steps = 52_263892)) ) test("Verify that announcing the winner fails if the bet token is not burned"): val player1 = TestUtil.mockPubKeyHash(1) val player2 = TestUtil.mockPubKeyHash(2) val oracle = TestUtil.mockPubKeyHash(3) val config = Config(player1, player2, oracle, expiration = 1753939940) val policyId = TestUtil.mockScriptHash(1) val tx = TestUtil.mockTxOutRef(1, 0) // Same as the success case but the NFT is NOT burned (it rides along to the winner) — the // bet must be one-shot, so this is rejected. val testTransaction = TxInfo.placeholder.copy( inputs = List( TxInInfo( outRef = tx, resolved = TxOut( address = Address.fromScriptHash(policyId), value = Value.lovelace(6_000_000) + Value(policyId, utf8"lucky_number_slevin", 1), datum = OutputDatum.OutputDatum(config.toData) ) ) ), outputs = List( TxOut( address = Address.fromPubKeyHash(player2), value = Value.lovelace(6_000_000) + Value(policyId, utf8"lucky_number_slevin", 1) ) ), // No mint: the token is not burned. signatories = List(oracle), validRange = Interval.between(1754027120, 1754027420) ) val result = contract.program.runWithDebug( ScriptContext( txInfo = testTransaction, redeemer = (Action.AnnounceWinner(player2, BigInt(0)): Action).toData, scriptInfo = ScriptInfo.SpendingScript(txOutRef = tx) ) ) assert(result.isFailure, "Announcing without burning the bet token must fail") assert( result.logs.exists(_.contains("must be burned")), s"Expected burn error, got: ${result.logs.mkString(", ")}" ) /** Build a joined-bet timeout transaction with the given outputs and validity range. */ private def timeoutTx( policyId: PolicyId, tx: TxOutRef, config: Config, outputs: List[TxOut], signatory: PubKeyHash, validRange: Interval ): TxInfo = TxInfo.placeholder.copy( inputs = List( TxInInfo( outRef = tx, resolved = TxOut( address = Address.fromScriptHash(policyId), value = Value.lovelace(6_000_000) + Value(policyId, utf8"lucky_number_slevin", 1), datum = OutputDatum.OutputDatum(config.toData) ) ) ), outputs = outputs, // The bet NFT is burned on timeout (one-shot). mint = Value(policyId, utf8"lucky_number_slevin", -1), signatories = List(signatory), validRange = validRange ) test("Verify that both players can reclaim the pot after a timeout"): val player1 = TestUtil.mockPubKeyHash(1) val player2 = TestUtil.mockPubKeyHash(2) val oracle = TestUtil.mockPubKeyHash(3) val config = Config(player1, player2, oracle, expiration = 1753939940) val policyId = TestUtil.mockScriptHash(1) val tx = TestUtil.mockTxOutRef(1, 0) // Each player is refunded their 3 ADA stake; the NFT is burned (not handed out). val outputs = List( TxOut( address = Address.fromPubKeyHash(player1), value = Value.lovelace(3_000_000) ), TxOut( address = Address.fromPubKeyHash(player2), value = Value.lovelace(3_000_000) ) ) // 1st of August 2025 — after expiration val result = contract.program.runWithDebug( ScriptContext( txInfo = timeoutTx(policyId, tx, config, outputs, player1, Interval.after(1754027120)), redeemer = (Action.Timeout: Action).toData, scriptInfo = ScriptInfo.SpendingScript(txOutRef = tx) ) ) if result.isFailure then result.logs.foreach(println) assert(result.isSuccess, "Reclaim after expiration should succeed") assert( result.budget == (ExUnits(memory = 229966, steps = 68_758189)) ) test("Verify that reclaim before expiration fails"): val player1 = TestUtil.mockPubKeyHash(1) val player2 = TestUtil.mockPubKeyHash(2) val oracle = TestUtil.mockPubKeyHash(3) val config = Config(player1, player2, oracle, expiration = 1753939940) val policyId = TestUtil.mockScriptHash(1) val tx = TestUtil.mockTxOutRef(1, 0) val outputs = List( TxOut( address = Address.fromPubKeyHash(player1), value = Value.lovelace(3_000_000) + Value(policyId, utf8"lucky_number_slevin", 1) ), TxOut(address = Address.fromPubKeyHash(player2), value = Value.lovelace(3_000_000)) ) // Validity entirely before expiration val result = contract.program.runWithDebug( ScriptContext( txInfo = timeoutTx( policyId, tx, config, outputs, player1, Interval.between(1752989540, 1752990020) ), redeemer = (Action.Timeout: Action).toData, scriptInfo = ScriptInfo.SpendingScript(txOutRef = tx) ) ) assert(result.isFailure, "Reclaim before expiration must fail") test("Verify that reclaim fails if a player is not refunded"): val player1 = TestUtil.mockPubKeyHash(1) val player2 = TestUtil.mockPubKeyHash(2) val oracle = TestUtil.mockPubKeyHash(3) val config = Config(player1, player2, oracle, expiration = 1753939940) val policyId = TestUtil.mockScriptHash(1) val tx = TestUtil.mockTxOutRef(1, 0) // player1 grabs the whole pot; player2 gets nothing val outputs = List( TxOut( address = Address.fromPubKeyHash(player1), value = Value.lovelace(6_000_000) + Value(policyId, utf8"lucky_number_slevin", 1) ) ) val result = contract.program.runWithDebug( ScriptContext( txInfo = timeoutTx(policyId, tx, config, outputs, player1, Interval.after(1754027120)), redeemer = (Action.Timeout: Action).toData, scriptInfo = ScriptInfo.SpendingScript(txOutRef = tx) ) ) assert(result.isFailure, "Reclaim must refund both players") ``` # Example: crowdfunding ## scalus-examples/jvm/src/main/scala/scalus/examples/crowdfunding/Crowdfunding.scala ```scala package scalus.examples.crowdfunding import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{ByteString, Data} import scalus.compiler.Options import scalus.cardano.onchain.plutus.v1.{Address, Credential, PubKeyHash} import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.uplc.PlutusV3 import scalus.compiler.Compile import scalus.cardano.blueprint.{Blueprint, Contract} // ============================================================================ // DATA MODELS // ============================================================================ /** Campaign datum representing the state of a crowdfunding campaign * * @param totalSum * Total amount collected in lovelace * @param goal * Funding goal in lovelace * @param recipient * Public key hash of the campaign recipient * @param deadline * POSIX time when the campaign ends * @param withdrawn * Total amount already withdrawn/reclaimed (for incremental operations) * @param donationPolicyId * Policy ID of the donation tokens for this campaign */ case class CampaignDatum( totalSum: BigInt, goal: BigInt, recipient: PubKeyHash, deadline: PosixTime, withdrawn: BigInt, donationPolicyId: PolicyId ) derives Data.FromData, Data.ToData @Compile object CampaignDatum { given Eq[CampaignDatum] = Eq.derived } /** Datum for donation UTxOs at the script address. * * Each donation UTxO at the script address contains: * - Donated ADA (may include extra for min UTxO) * - Donation token (proves donation exists) * - This datum (identifies donor and stores actual donation amount) * * @param donor * Public key hash of the original donor (for reclaim authorization) * @param amount * The actual donation amount in lovelace */ case class DonationDatum( donor: PubKeyHash, amount: BigInt ) derives Data.FromData, Data.ToData @Compile object DonationDatum { given Eq[DonationDatum] = Eq.derived } /** Actions that can be performed on the crowdfunding contract * * All actions use indexed UTxO pattern for O(1) lookups. Indices are computed off-chain using * delayed redeemer pattern. */ enum Action derives Data.FromData, Data.ToData: /** Create a new campaign by minting campaign NFT */ case Create(goal: BigInt, recipient: PubKeyHash, deadline: PosixTime) /** Donate to a campaign * @param amount * Donation amount in lovelace * @param campaignInputIdx * Index of campaign input in txInfo.inputs * @param campaignOutputIdx * Index of campaign output in txInfo.outputs * @param donationOutputIdx * Index of donation value output in txInfo.outputs */ case Donate( amount: BigInt, campaignInputIdx: BigInt, campaignOutputIdx: BigInt, donationOutputIdx: BigInt ) /** Withdraw funds (recipient claims after successful campaign) * @param campaignInputIdx * Index of campaign input * @param campaignOutputIdx * Index of campaign output (-1 if fully withdrawn) * @param recipientOutputIdx * Index of recipient's payout output * @param donationInputIndices * Indices of donation UTxOs being consumed */ case Withdraw( campaignInputIdx: BigInt, campaignOutputIdx: BigInt, recipientOutputIdx: BigInt, donationInputIndices: List[BigInt] ) /** Reclaim funds (token holders reclaim after failed campaign) * @param campaignInputIdx * Index of campaign input * @param campaignOutputIdx * Index of campaign output (-1 if fully reclaimed) * @param donationInputIndices * Indices of donation UTxOs being consumed * @param reclaimerOutputIndices * Indices of outputs returning funds to token holders */ case Reclaim( campaignInputIdx: BigInt, campaignOutputIdx: BigInt, donationInputIndices: List[BigInt], reclaimerOutputIndices: List[BigInt] ) // ============================================================================ // DONATION MINTING POLICY (Parameterized by campaignId) // ============================================================================ /** Minting policy for donation tokens, parameterized by campaign ID. * * Token name = donation amount encoded as ByteString. This makes tokens transferable and fungible * by amount - whoever holds the token can withdraw/reclaim that amount. * * Security: Burning requires the campaign UTxO to be spent in the same transaction. The campaign * spending is validated by CrowdfundingValidator which enforces deadline and goal conditions. */ @Compile object DonationMintingPolicy { /** Fixed token name for all donation tokens. * * The donation amount is stored in the UTxO's lovelace value, not encoded in the token name. * This simplifies the design and avoids integer encoding/overflow issues. */ val donationTokenName: ByteString = ByteString.empty inline def validate(param: Data)(scData: Data): Unit = { val sc = scData.to[ScriptContext] sc.scriptInfo match case ScriptInfo.MintingScript(policyId) => val campaignId = param.to[ByteString] val action = sc.redeemer.to[Action] action match case Action.Donate(amount, campaignInputIdx, _, _) => handleDonateMint(campaignId, policyId, sc.txInfo, amount, campaignInputIdx) case Action.Withdraw(campaignInputIdx, _, _, _) => handleBurn(campaignId, policyId, sc.txInfo, campaignInputIdx) case Action.Reclaim(campaignInputIdx, _, _, _) => handleBurn(campaignId, policyId, sc.txInfo, campaignInputIdx) case Action.Create(_, _, _) => fail("Create action does not mint donation tokens") case _ => fail("Unsupported script purpose") } private inline def handleDonateMint( campaignId: ByteString, policyId: PolicyId, txInfo: TxInfo, amount: BigInt, campaignInputIdx: BigInt ): Unit = // 1. Verify amount is positive require(amount > BigInt(0), "Donation amount must be positive") // 2. Find campaign input and verify it has the campaign NFT val campaignInput = txInfo.inputs.at(campaignInputIdx) val campaignDatum = campaignInput.resolved.datum match case OutputDatum.OutputDatum(d) => d.to[CampaignDatum] case _ => fail("Campaign must have inline datum") // 3. Verify this donation policy matches the campaign's expected policy require( campaignDatum.donationPolicyId === policyId, "Donation policy must match campaign's expected policy" ) // 4. Verify we're before deadline require( txInfo.validRange.isEntirelyBefore(campaignDatum.deadline), "Donations must be before deadline" ) // 5. Verify exactly one donation token is minted require( txInfo.mint.quantityOf(policyId, donationTokenName) === BigInt(1), "Exactly one donation token must be minted" ) // 6. Verify no other tokens are minted under this policy (V011 protection) val allMintedUnderPolicy = txInfo.mint.flatten.filter { case (pid, _, _) => pid === policyId } require( allMintedUnderPolicy.length === BigInt(1), "Only one token type may be minted under donation policy" ) /** Handle burning of donation tokens. * * Security: Verifies that the campaign UTxO is being spent in this transaction. The campaign * spending goes through CrowdfundingValidator which validates all conditions (deadline, goal, * signatures). */ private inline def handleBurn( campaignId: ByteString, policyId: PolicyId, txInfo: TxInfo, campaignInputIdx: BigInt ): Unit = // 1. Verify campaign UTxO is being spent (this triggers CrowdfundingValidator) val campaignInput = txInfo.inputs.at(campaignInputIdx) val campaignDatum = campaignInput.resolved.datum match case OutputDatum.OutputDatum(d) => d.to[CampaignDatum] case _ => fail("Campaign must have inline datum") // 2. Verify this is the correct campaign by checking donation policy matches require( campaignDatum.donationPolicyId === policyId, "Campaign donation policy must match this policy" ) // 3. All tokens of this policy must be burned (negative quantity) val mintedTokens = txInfo.mint.tokens(policyId) require( mintedTokens.forall { case (_, amount) => amount < BigInt(0) }, "Only burning allowed during withdraw/reclaim" ) } // ============================================================================ // CROWDFUNDING VALIDATOR (Main Script) // ============================================================================ /** Main crowdfunding validator handling campaign NFT minting and UTxO spending. */ @Compile object CrowdfundingValidator extends Validator { inline override def spend( @annotation.unused datum: Option[Data], redeemer: Data, txInfo: TxInfo, txOutRef: TxOutRef ): Unit = redeemer.to[Action] match case Action.Donate(amount, campaignInputIdx, campaignOutputIdx, donationOutputIdx) => val input = txInfo.inputs.at(campaignInputIdx) require(input.outRef === txOutRef, "Input index does not match txOutRef") val (scriptHash, currentDatum) = input.resolved match case TxOut( Address(Credential.ScriptCredential(sh), _), _, OutputDatum.OutputDatum(inlineDatum), _ ) => (sh, inlineDatum.to[CampaignDatum]) case _ => fail("Campaign input must have script credential and inline datum") handleDonateSpend( txInfo, scriptHash, currentDatum, amount, campaignOutputIdx, donationOutputIdx ) case Action.Withdraw( campaignInputIdx, campaignOutputIdx, recipientOutputIdx, donationInputIndices ) => val campaignInput = txInfo.inputs.at(campaignInputIdx) // Check if this is the campaign UTxO or a donation value UTxO if campaignInput.outRef === txOutRef then // This is the campaign UTxO - do full validation val (scriptHash, currentDatum) = campaignInput.resolved match case TxOut( Address(Credential.ScriptCredential(sh), _), value, OutputDatum.OutputDatum(inlineDatum), _ ) => // Verify campaign NFT exists (policyId = scriptHash) verifyCampaignNftPresent(value, sh) (sh, inlineDatum.to[CampaignDatum]) case _ => fail("Campaign input must have script credential and inline datum") handleWithdrawSpend( txInfo, scriptHash, currentDatum, campaignOutputIdx, recipientOutputIdx, donationInputIndices ) else // This is a donation value UTxO - just verify it's in the list val isInDonationList = donationInputIndices.exists { idx => txInfo.inputs.at(idx).outRef === txOutRef } require(isInDonationList, "Donation UTxO must be in donationInputIndices") case Action.Reclaim( campaignInputIdx, campaignOutputIdx, donationInputIndices, reclaimerOutputIndices ) => val campaignInput = txInfo.inputs.at(campaignInputIdx) // Check if this is the campaign UTxO or a donation value UTxO if campaignInput.outRef === txOutRef then // This is the campaign UTxO - do full validation val (scriptHash, currentDatum) = campaignInput.resolved match case TxOut( Address(Credential.ScriptCredential(sh), _), value, OutputDatum.OutputDatum(inlineDatum), _ ) => // Verify campaign NFT exists (policyId = scriptHash) verifyCampaignNftPresent(value, sh) (sh, inlineDatum.to[CampaignDatum]) case _ => fail("Campaign input must have script credential and inline datum") handleReclaimSpend( txInfo, scriptHash, currentDatum, campaignOutputIdx, donationInputIndices, reclaimerOutputIndices ) else // This is a donation value UTxO - just verify it's in the list val isInDonationList = donationInputIndices.exists { idx => txInfo.inputs.at(idx).outRef === txOutRef } require(isInDonationList, "Donation UTxO must be in donationInputIndices") case Action.Create(_, _, _) => fail("Create action is only valid for minting") /** Handle donation spend - validates campaign UTxO update */ private inline def handleDonateSpend( txInfo: TxInfo, scriptHash: ValidatorHash, currentDatum: CampaignDatum, amount: BigInt, campaignOutputIdx: BigInt, donationOutputIdx: BigInt ): Unit = // 1. Time validation: must be before deadline require( txInfo.validRange.isEntirelyBefore(currentDatum.deadline), "Donation must be before deadline" ) // 2. Amount must be positive require(amount > BigInt(0), "Donation amount must be positive") // 3. Verify continuing campaign output val campaignOutput = txInfo.outputs.at(campaignOutputIdx) val newDatum = campaignOutput.datum match case OutputDatum.OutputDatum(d) => d.to[CampaignDatum] case _ => fail("Campaign output must have inline datum") // 4. Verify datum update - only totalSum should change val expectedDatum = CampaignDatum( totalSum = currentDatum.totalSum + amount, goal = currentDatum.goal, recipient = currentDatum.recipient, deadline = currentDatum.deadline, withdrawn = currentDatum.withdrawn, donationPolicyId = currentDatum.donationPolicyId ) require(newDatum === expectedDatum, "Updated datum must reflect donation") // 5. Verify donation UTxO is created at script address with token + ADA + DonationDatum val donationOutput = txInfo.outputs.at(donationOutputIdx) require( donationOutput.address === Address.fromScriptHash(scriptHash), "Donation output must go to script address" ) require( donationOutput.value.getLovelace >= amount, "Donation output must contain at least the donation amount" ) // 6. Verify donation token is minted and goes to donation UTxO val tokenName = DonationMintingPolicy.donationTokenName require( txInfo.mint.quantityOf(currentDatum.donationPolicyId, tokenName) === BigInt(1), "Donation token must be minted" ) require( donationOutput.value.quantityOf(currentDatum.donationPolicyId, tokenName) === BigInt(1), "Donation token must be in donation UTxO" ) // 7. Verify donation UTxO has DonationDatum with correct amount donationOutput.datum match case OutputDatum.OutputDatum(d) => val donationDatum = d.to[DonationDatum] require( donationDatum.amount === amount, "DonationDatum must contain correct amount" ) case _ => fail("Donation output must have inline DonationDatum") /** Handle withdraw spend - validates fund transfer to recipient */ private inline def handleWithdrawSpend( txInfo: TxInfo, scriptHash: ValidatorHash, currentDatum: CampaignDatum, campaignOutputIdx: BigInt, recipientOutputIdx: BigInt, donationInputIndices: List[BigInt] ): Unit = // 1. Time validation: must be after deadline require( txInfo.validRange.isEntirelyAfter(currentDatum.deadline), "Withdraw only allowed after deadline" ) // 2. Goal must be reached require( currentDatum.totalSum >= currentDatum.goal, "Goal must be reached for withdrawal" ) // 3. Recipient must sign require( txInfo.isSignedBy(currentDatum.recipient), "Recipient must sign withdrawal" ) // 4. Verify donation indices are unique (prevents double-spend attack) requireStrictlyAscending(donationInputIndices) // 5. Calculate total being withdrawn from donation inputs val totalWithdrawn = donationInputIndices.foldLeft(BigInt(0)) { (sum, idx) => val donationInput = txInfo.inputs.at(idx) sum + donationInput.resolved.value.getLovelace } // 6. Verify recipient receives the funds val recipientOutput = txInfo.outputs.at(recipientOutputIdx) require( recipientOutput.address === Address.fromPubKeyHash(currentDatum.recipient), "Funds must go to recipient" ) require( recipientOutput.value.getLovelace >= totalWithdrawn, "Recipient must receive withdrawn amount" ) // 7. Verify donation tokens are burned verifyDonationsBurned(txInfo, currentDatum.donationPolicyId, donationInputIndices) // 8. Verify campaign output or removal val newWithdrawn = currentDatum.withdrawn + totalWithdrawn if newWithdrawn === currentDatum.totalSum then // Full withdrawal - campaign is complete () else // Partial withdrawal - verify updated campaign datum val campaignOutput = txInfo.outputs.at(campaignOutputIdx) val newDatum = campaignOutput.datum match case OutputDatum.OutputDatum(d) => d.to[CampaignDatum] case _ => fail("Campaign output must have inline datum") // Verify all immutable fields remain unchanged, only withdrawn updates (V015 protection) val expectedDatum = CampaignDatum( totalSum = currentDatum.totalSum, goal = currentDatum.goal, recipient = currentDatum.recipient, deadline = currentDatum.deadline, withdrawn = newWithdrawn, donationPolicyId = currentDatum.donationPolicyId ) require(newDatum === expectedDatum, "Only withdrawn field may change") // Verify campaign NFT is preserved in output verifyCampaignNftPresent(campaignOutput.value, scriptHash) /** Handle reclaim spend - validates fund return to token holders */ private inline def handleReclaimSpend( txInfo: TxInfo, scriptHash: ValidatorHash, currentDatum: CampaignDatum, campaignOutputIdx: BigInt, donationInputIndices: List[BigInt], reclaimerOutputIndices: List[BigInt] ): Unit = // 1. Time validation: must be after deadline require( txInfo.validRange.isEntirelyAfter(currentDatum.deadline), "Reclaim only allowed after deadline" ) // 2. Goal must NOT be reached require( currentDatum.totalSum < currentDatum.goal, "Cannot reclaim if goal was reached" ) // 3. Verify donation indices are unique (prevents double-spend attack) requireStrictlyAscending(donationInputIndices) // 3a. Every consumed donation must have its own distinct refund output. The // requireStrictlyAscending check above only constrains donationInputIndices, NOT the // reclaimerOutputIndices used below — the sweep lives in that second list, which step 4 // pairs against the donations via `zip`. Two independent guards are needed, neither // implied by the ascending check: // - Equal length: `zip` silently truncates to the shorter list, so supplying fewer // reclaimer outputs than donations leaves the unpaired donations' ADA to exit as // change (their tokens are still burned). Distinctness can't catch this — a shorter // prefix is still distinct. // - Distinct outputs: a reused index (e.g. [0, 0]) points several donations at one // payout, sweeping the rest. The length check can't catch this — [0, 0] has the // right length. (Strictly-ascending would also work but would force an output // ordering the off-chain builder doesn't guarantee; distinctness is order-free.) require( donationInputIndices.length === reclaimerOutputIndices.length, "Reclaimer output count must match donation count" ) requireDistinct(reclaimerOutputIndices) // 4. Verify each donation is returned to the original donor (from DonationDatum) val totalReclaimed = donationInputIndices.zip(reclaimerOutputIndices).foldLeft(BigInt(0)) { case (sum, (donationIdx, reclaimerOutIdx)) => val donationInput = txInfo.inputs.at(donationIdx) // Get donor from DonationDatum and full UTxO value val donationDatum = donationInput.resolved.datum match case OutputDatum.OutputDatum(d) => d.to[DonationDatum] case _ => fail("Donation input must have inline DonationDatum") val donorPkh = donationDatum.donor val donationAmount = donationDatum.amount // Use actual UTxO lovelace to include min UTxO overhead val utxoLovelace = donationInput.resolved.value.getLovelace // Verify funds go to the original donor val reclaimerOutput = txInfo.outputs.at(reclaimerOutIdx) require( reclaimerOutput.address === Address.fromPubKeyHash(donorPkh), "Funds must return to original donor" ) // Exact match required to prevent min UTxO theft (V009 protection) require( reclaimerOutput.value.getLovelace === utxoLovelace, "Donor must receive exact UTxO value" ) sum + donationAmount } // 5. Verify donation tokens are burned verifyDonationsBurned(txInfo, currentDatum.donationPolicyId, donationInputIndices) // 6. Verify campaign output or removal val newWithdrawn = currentDatum.withdrawn + totalReclaimed if newWithdrawn === currentDatum.totalSum then // Full reclaim - campaign is complete () else // Partial reclaim - verify updated campaign datum val campaignOutput = txInfo.outputs.at(campaignOutputIdx) val newDatum = campaignOutput.datum match case OutputDatum.OutputDatum(d) => d.to[CampaignDatum] case _ => fail("Campaign output must have inline datum") // Verify all immutable fields remain unchanged, only withdrawn updates (V015 protection) val expectedDatum = CampaignDatum( totalSum = currentDatum.totalSum, goal = currentDatum.goal, recipient = currentDatum.recipient, deadline = currentDatum.deadline, withdrawn = newWithdrawn, donationPolicyId = currentDatum.donationPolicyId ) require(newDatum === expectedDatum, "Only withdrawn field may change") // Verify campaign NFT is preserved in output verifyCampaignNftPresent(campaignOutput.value, scriptHash) /** Verify that the campaign UTxO contains exactly one campaign NFT. * * This prevents attacks using fake campaign UTxOs without the NFT. The campaign NFT has * policyId = scriptHash, so we check for exactly one token from that policy. */ def verifyCampaignNftPresent(value: Value, scriptHash: ValidatorHash): Unit = val nftTokens = value.tokens(scriptHash) // Must have exactly one token type with quantity 1 val hasExactlyOneNft = nftTokens.size === BigInt(1) && nftTokens.forall { case (_, qty) => qty === BigInt(1) } require(hasExactlyOneNft, "Campaign input must contain exactly one campaign NFT") /** Verify that indices are strictly ascending (which guarantees uniqueness). * * This prevents double-spending attacks where the same donation UTxO index is referenced * multiple times in the redeemer. */ def requireStrictlyAscending(indices: List[BigInt]): Unit = // Use fold to check consecutive pairs: track previous value, verify each is greater // Start with minimum possible value so first element always passes indices.foldLeft(BigInt(-1)) { (prev, curr) => require(prev < curr, "Donation indices must be strictly ascending (no duplicates)") curr } () /** Verify that indices are pairwise distinct, without imposing an ordering. * * Reclaimer output indices need not be sorted (the off-chain builder lays out outputs in its * own order), but they must not repeat — otherwise two donations could be refunded by a single * output. */ def requireDistinct(indices: List[BigInt]): Unit = indices.foldLeft(List.empty[BigInt]) { (seen, curr) => require(!seen.contains(curr), "Reclaimer output indices must be distinct") List.Cons(curr, seen) } () /** Verify that donation tokens are burned for the given donation inputs. * * Gets the token name from the donation UTxO's tokens (not from lovelace amount, which may * include extra for min UTxO requirements). */ private inline def verifyDonationsBurned( txInfo: TxInfo, donationPolicyId: PolicyId, donationInputIndices: List[BigInt] ): Unit = val tokenName = DonationMintingPolicy.donationTokenName // Count donation tokens and verify each input has exactly 1 val tokenCount = donationInputIndices.foldLeft(BigInt(0)) { (count, idx) => val donationInput = txInfo.inputs.at(idx) val tokens = donationInput.resolved.value.tokens(donationPolicyId) val hasOneToken = tokens.get(tokenName) match case Option.Some(qty) => tokens.size === BigInt(1) && qty === BigInt(1) case Option.None => false require(hasOneToken, "Donation input must have exactly 1 donation token") count + BigInt(1) } // Verify exact number of tokens are burned require( txInfo.mint.quantityOf(donationPolicyId, tokenName) === -tokenCount, "All donation tokens must be burned" ) inline override def mint( redeemer: Data, policyId: PolicyId, txInfo: TxInfo ): Unit = redeemer.to[Action] match case Action.Create(goal, recipient, deadline) => handleCreateMint(policyId, txInfo, goal, recipient, deadline) case _ => // Burning campaign NFT is allowed at end handleBurn(policyId, txInfo) private inline def handleCreateMint( policyId: PolicyId, txInfo: TxInfo, goal: BigInt, recipient: PubKeyHash, deadline: PosixTime ): Unit = // 1. Recipient must sign require( txInfo.isSignedBy(recipient), "Recipient must sign campaign creation" ) // 2. Goal must be positive require(goal > BigInt(0), "Goal must be positive") // 3. Deadline must be in the future require( txInfo.validRange.isEntirelyBefore(deadline), "Deadline must be in the future" ) // 4. Find a consumed UTxO to derive unique campaign ID val consumedUtxo = txInfo.inputs.match case List.Cons(first, _) => first.outRef case List.Nil => fail("Must consume at least one UTxO") // Hash the serialized TxOutRef to get a 32-byte campaign ID (AssetName limit) val campaignId = scalus.uplc.builtin.Builtins.blake2b_256( scalus.uplc.builtin.Builtins.serialiseData(consumedUtxo.toData) ) // 5. Verify exactly one campaign NFT is minted require( txInfo.mint.quantityOf(policyId, campaignId) === BigInt(1), "Exactly one campaign NFT must be minted" ) // 5a. Verify no other tokens are minted under this policy (V011 protection) val allMintedUnderPolicy = txInfo.mint.flatten.filter { case (pid, _, _) => pid === policyId } require( allMintedUnderPolicy.length === BigInt(1), "Only one token type may be minted under campaign policy" ) // 6. Find the output going to the script address val campaignOutput = txInfo.outputs.filter { out => out.address === Address.fromScriptHash(policyId) }.match case List.Cons(out, List.Nil) => out case _ => fail("There must be exactly one output to the campaign script") // 7. Verify the output contains the minted NFT require( campaignOutput.value.quantityOf(policyId, campaignId) === BigInt(1), "Campaign output must contain the minted NFT" ) // 8. Get the donation policy ID from datum (computed off-chain) val donationPolicyId = campaignOutput.datum match case OutputDatum.OutputDatum(d) => d.to[CampaignDatum].donationPolicyId case _ => fail("Campaign output must have inline datum") // 9. Verify the datum is correct val expectedDatum = CampaignDatum( totalSum = BigInt(0), goal = goal, recipient = recipient, deadline = deadline, withdrawn = BigInt(0), donationPolicyId = donationPolicyId ) campaignOutput.datum match case OutputDatum.OutputDatum(datumData) => require( datumData.to[CampaignDatum] === expectedDatum, "Initial campaign datum must be correct" ) case _ => fail("Campaign output must have inline datum") private inline def handleBurn( policyId: PolicyId, txInfo: TxInfo ): Unit = val mintedTokens = txInfo.mint.tokens(policyId) require( mintedTokens.forall { case (_, amount) => amount < BigInt(0) }, "Only burning is allowed" ) } // ============================================================================ // COMPILATION // ============================================================================ /** Main crowdfunding script: mints the campaign NFT and guards campaign/donation spends. */ object CrowdfundingContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(CrowdfundingValidator.validate) lazy val blueprint = Blueprint.plutusV3[CampaignDatum, Action]( title = "Crowdfunding campaign", description = "Goal-based crowdfunding: donors lock funds with a per-donation token; the recipient " + "withdraws once the goal is met after the deadline, otherwise donors reclaim their " + "contributions by burning the donation tokens.", version = "1.0.0", license = Some("Apache-2.0"), compiled = compiled ) } /** Donation minting policy: mints one donation token per contribution, burns them on reclaim. */ object DonationMintingContract extends Contract { private given Options = Options.release lazy val compiled: PlutusV3[Data => Data => Unit] = PlutusV3.compile(DonationMintingPolicy.validate) lazy val blueprint = Blueprint.plutusV3[ByteString, Action]( title = "Crowdfunding donation minting policy", description = "Parameterized by a campaign id. Mints a donation token for each contribution and only " + "allows burning during withdraw/reclaim, proving how many donations a campaign " + "received.", version = "1.0.0", license = Some("Apache-2.0"), // DonationMintingPolicy applies its campaign-id parameter as Data on the UPLC level, so // `compiled` is typed `Data => Data => Unit`. The cast only re-labels the phantom type so the // parameter schema is derived as ByteString; the compiled program is unchanged. compiled = compiled.asInstanceOf[PlutusV3[ByteString => Data => Unit]] ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/crowdfunding/CrowdfundingEndpoints.scala ```scala package scalus.examples.crowdfunding import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{ByteString, Data} import scalus.cardano.address.{Address as CardanoAddress, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart} import scalus.cardano.ledger.{AddrKeyHash, AssetName, CardanoInfo, Coin, Script, ScriptHash, Transaction, Utxo, Value as LedgerValue} import scalus.cardano.node.BlockchainProvider import scalus.cardano.txbuilder.{TransactionSigner, TxBuilder} import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.uplc.PlutusV3 import java.time.Instant import scala.concurrent.Future /** Endpoints for building crowdfunding transactions. * * Handles off-chain transaction construction using the delayed redeemer pattern for computing UTxO * indices. * * @param provider * Node provider for querying UTxOs and submitting transactions * @param crowdfundingContract * Compiled crowdfunding validator * @param donationMintingContract * Compiled donation minting policy (parameterized) */ class CrowdfundingEndpoints( provider: BlockchainProvider, crowdfundingContract: PlutusV3[Data => Unit], donationMintingContract: PlutusV3[Data => Data => Unit] ) { private def env: CardanoInfo = provider.cardanoInfo private val crowdfundingPolicyId = crowdfundingContract.script.scriptHash def scriptAddress: CardanoAddress = crowdfundingContract.address(env.network) /** Extract PubKeyHash from a ShelleyAddress */ private def extractPkh(address: ShelleyAddress): PubKeyHash = address.payment match case ShelleyPaymentPart.Key(hash) => PubKeyHash(hash) case _ => throw IllegalArgumentException("Address must have key payment credential") /** Create a ShelleyAddress from a PubKeyHash */ private def addressFromPkh(pkh: PubKeyHash): ShelleyAddress = ShelleyAddress( env.network, ShelleyPaymentPart.Key(AddrKeyHash.fromByteString(pkh.hash)), ShelleyDelegationPart.Null ) /** Validates that an index was found (not -1) and returns it, or throws with descriptive error. */ private def requireFound(idx: Int, msg: String): Int = if idx == -1 then throw RuntimeException(msg) else idx /** Apply campaign ID to donation minting policy and get the script. * * The donation minting policy is parameterized by campaignId, resulting in a unique policyId * per campaign. */ private def getDonationScript(campaignId: ByteString): Script.PlutusV3 = val appliedProgram = donationMintingContract.program $ campaignId.toData Script.PlutusV3(appliedProgram.cborByteString) /** Compute the donation policy ID for a given campaign ID. */ def computeDonationPolicyId(campaignId: ByteString): ByteString = getDonationScript(campaignId).scriptHash /** Creates a new crowdfunding campaign. * * Mints a campaign NFT and creates the initial campaign UTxO with the specified parameters. * * @param recipientAddress * Address of the campaign recipient (receives funds if goal is reached) * @param goal * Funding goal in lovelace * @param deadline * POSIX timestamp when the campaign ends * @param initialValue * Initial ADA locked with the campaign (for min UTxO requirements) * @param signer * Transaction signer with recipient's keys * @return * The submitted transaction and the campaign ID */ def createCampaign( recipientAddress: ShelleyAddress, goal: Long, deadline: Long, initialValue: Coin, signer: TransactionSigner ): Future[(Transaction, ByteString)] = given scala.concurrent.ExecutionContext = provider.executionContext val recipientPkh = extractPkh(recipientAddress) val recipientAddrKeyHash = AddrKeyHash.fromByteString(recipientPkh.hash) for // Get UTxOs to find one for deriving campaign ID utxos <- provider .findUtxos(recipientAddress) .map(_.getOrElse(Map.empty)) _ = if utxos.isEmpty then throw RuntimeException("No UTxOs found at recipient address") // Use first UTxO to derive campaign ID (same as validator does) // Hash the serialized TxOutRef to get a 32-byte campaign ID (AssetName limit) firstUtxo = utxos.head txOutRef = scalus.cardano.onchain.plutus.v3.TxOutRef( scalus.cardano.onchain.plutus.v3.TxId(firstUtxo._1.transactionId), firstUtxo._1.index ) campaignId = scalus.uplc.builtin.Builtins.blake2b_256( scalus.uplc.builtin.Builtins.serialiseData(txOutRef.toData) ) // Compute donation policy ID for this campaign donationPolicyId = computeDonationPolicyId(campaignId) datum = CampaignDatum( totalSum = BigInt(0), goal = BigInt(goal), recipient = recipientPkh, deadline = BigInt(deadline), withdrawn = BigInt(0), donationPolicyId = donationPolicyId ) redeemer = Action.Create( goal = BigInt(goal), recipient = recipientPkh, deadline = BigInt(deadline) ) nftAsset = AssetName(campaignId) mintedValue = LedgerValue.asset(crowdfundingPolicyId, nftAsset, 1L) // Create UTxO object from the first utxo (which is used to derive campaignId) seedUtxo = Utxo(firstUtxo._1, firstUtxo._2) tx <- TxBuilder(env) .spend( seedUtxo ) // Must spend this UTxO - validator derives campaignId from first input .mint( crowdfundingContract, Map(nftAsset -> 1L), redeemer ) .requireSignature(recipientAddrKeyHash) .payTo(scriptAddress, LedgerValue(initialValue) + mintedValue, datum) .validTo(Instant.ofEpochMilli(deadline - 1000)) .complete(provider, recipientAddress) .map(_.sign(signer).transaction) _ <- provider.submit(tx).map { case Right(_) => () case Left(error) => throw RuntimeException(s"Failed to submit: $error") } yield (tx, campaignId) /** Donates to a campaign. * * Creates a donation by: * - Spending the campaign UTxO and updating its totalSum * - Minting a donation token (token name = encoded amount) * - Creating a donation value UTxO at the script address * - Sending the donation token to the donor * * @param campaignId * The campaign identifier (token name of campaign NFT) * @param donorAddress * Address of the donor * @param amount * Donation amount in lovelace * @param signer * Transaction signer with donor's keys * @return * The submitted transaction */ def donate( campaignId: ByteString, donorAddress: ShelleyAddress, amount: Long, signer: TransactionSigner ): Future[Transaction] = given scala.concurrent.ExecutionContext = provider.executionContext for campaignUtxo <- findCampaignUtxo(campaignId).map( _.getOrElse(throw RuntimeException(s"No campaign found for id: $campaignId")) ) currentDatum = campaignUtxo.output.inlineDatum .getOrElse(throw IllegalStateException("Campaign UTxO must have inline datum")) .to[CampaignDatum] // Compute donation policy and script for this campaign donationPolicyId = ScriptHash.fromByteString(currentDatum.donationPolicyId) donationScript = getDonationScript(campaignId) // Create updated campaign datum newDatum = currentDatum.copy( totalSum = currentDatum.totalSum + BigInt(amount) ) // Donation token: fixed name (amount stored in DonationDatum) donationAsset = AssetName(DonationMintingPolicy.donationTokenName) donationTokenValue = LedgerValue.asset(donationPolicyId, donationAsset, 1L) // Campaign NFT must be preserved nftAsset = AssetName(campaignId) nftValue = LedgerValue.asset(crowdfundingPolicyId, nftAsset, 1L) // New campaign value = current + donation amount newCampaignValue = LedgerValue.lovelace( campaignUtxo.output.value.coin.value + amount ) + nftValue // Unified donation UTxO: ADA + donation token (at script address) donationUtxoValue = LedgerValue.lovelace(amount) + donationTokenValue // DonationDatum stores donor and amount (for reclaim) donorPkh = extractPkh(donorAddress) donationDatum = DonationDatum(donorPkh, BigInt(amount)) // Build transaction with delayed redeemer donateRedeemer = (tx: Transaction) => { val inputIdx = requireFound( tx.body.value.inputs.toSeq.indexOf(campaignUtxo.input), "Campaign input not found in transaction" ) val campaignOutputIdx = requireFound( tx.body.value.outputs.indexWhere { sized => sized.value.address == scriptAddress && sized.value.value.assets.assets .get(crowdfundingPolicyId) .exists(_.get(nftAsset).exists(_ > 0)) }, "Campaign output not found in transaction" ) val donationOutputIdx = requireFound( tx.body.value.outputs.indexWhere { sized => sized.value.address == scriptAddress && !sized.value.value.assets.assets .get(crowdfundingPolicyId) .exists(_.get(nftAsset).exists(_ > 0)) }, "Donation output not found in transaction" ) Action .Donate( BigInt(amount), BigInt(inputIdx), BigInt(campaignOutputIdx), BigInt(donationOutputIdx) ) .toData } tx <- TxBuilder(env) .spend(campaignUtxo, donateRedeemer, crowdfundingContract) .mint(donationScript, Map(donationAsset -> 1L), donateRedeemer) .payTo(scriptAddress, newCampaignValue, newDatum) // Updated campaign UTxO // Unified donation UTxO: ADA + donation token + DonationDatum (at script address) .payTo(scriptAddress, donationUtxoValue, donationDatum) .validTo(Instant.ofEpochMilli(currentDatum.deadline.toLong - 1000)) .complete(provider, donorAddress) .map(_.sign(signer).transaction) _ <- provider.submit(tx).map { case Right(_) => () case Left(error) => throw RuntimeException(s"Failed to submit: $error") } yield tx /** Withdraws funds from a successful campaign. * * After the deadline, if the goal is reached, the recipient can withdraw donated funds by * burning the corresponding donation tokens. * * @param campaignId * The campaign identifier * @param recipientAddress * Address of the campaign recipient * @param donationUtxos * Donation UTxOs at script address (contain both tokens and ADA) * @param signer * Transaction signer with recipient's keys (no donor signatures needed - tokens are at * script address) * @return * The submitted transaction */ def withdraw( campaignId: ByteString, recipientAddress: ShelleyAddress, donationUtxos: Seq[Utxo], signer: TransactionSigner ): Future[Transaction] = given scala.concurrent.ExecutionContext = provider.executionContext val recipientPkh = extractPkh(recipientAddress) val recipientAddrKeyHash = AddrKeyHash.fromByteString(recipientPkh.hash) for campaignUtxo <- findCampaignUtxo(campaignId).map( _.getOrElse(throw RuntimeException(s"No campaign found for id: $campaignId")) ) currentDatum = campaignUtxo.output.inlineDatum .getOrElse(throw IllegalStateException("Campaign UTxO must have inline datum")) .to[CampaignDatum] // Verify recipient matches _ = if currentDatum.recipient != recipientPkh then throw RuntimeException("Only campaign recipient can withdraw") donationPolicyId = ScriptHash.fromByteString(currentDatum.donationPolicyId) donationScript = getDonationScript(campaignId) // Calculate total amount being withdrawn from donation UTxOs (get amount from DonationDatum) donationAmounts: Seq[BigInt] = donationUtxos.map { utxo => utxo.output.inlineDatum .getOrElse(throw IllegalStateException("Donation UTxO must have inline datum")) .to[DonationDatum] .amount } totalWithdrawAmount: BigInt = donationAmounts.foldLeft(BigInt(0))(_ + _) // Calculate if this is full or partial withdrawal newWithdrawn = currentDatum.withdrawn + totalWithdrawAmount isFullWithdrawal = newWithdrawn == currentDatum.totalSum // Build burn map for donation tokens (all have same fixed name) donationAsset = AssetName(DonationMintingPolicy.donationTokenName) totalTokensToBurn = donationUtxos.size.toLong burnMap = Map(donationAsset -> -totalTokensToBurn) nftAsset = AssetName(campaignId) // Helper to build the Withdraw redeemer withdrawRedeemer = (tx: Transaction) => { val inputIdx = requireFound( tx.body.value.inputs.toSeq.indexOf(campaignUtxo.input), "Campaign input not found in transaction" ) val campaignOutputIdx = if isFullWithdrawal then -1 else requireFound( tx.body.value.outputs.indexWhere { sized => sized.value.address == scriptAddress && sized.value.value.assets.assets .get(crowdfundingPolicyId) .exists(_.get(nftAsset).exists(_ > 0)) }, "Campaign output not found for partial withdrawal" ) val recipientOutputIdx = requireFound( tx.body.value.outputs.indexWhere { sized => sized.value.address == recipientAddress }, "Recipient output not found in transaction" ) // Sort indices to satisfy validator's strictly ascending requirement val donationInputIndices = donationUtxos.map { utxo => BigInt( requireFound( tx.body.value.inputs.toSeq.indexOf(utxo.input), "Donation input not found in transaction" ) ) }.sorted Action .Withdraw( BigInt(inputIdx), BigInt(campaignOutputIdx), BigInt(recipientOutputIdx), scalus.cardano.onchain.plutus.prelude.List.from(donationInputIndices) ) .toData } // Build transaction: spend campaign UTxO builderWithCampaign = TxBuilder(env) .spend( campaignUtxo, redeemerBuilder = withdrawRedeemer, crowdfundingContract ) .requireSignature(recipientAddrKeyHash) // Spend donation UTxOs (at script address - no donor signatures needed) builderWithDonations = donationUtxos.foldLeft(builderWithCampaign) { (builder, utxo) => builder.spend(utxo, withdrawRedeemer, crowdfundingContract) } // Burn donation tokens builderWithBurn = builderWithDonations.mint(donationScript, burnMap, withdrawRedeemer) // Pay recipient builderWithRecipient = builderWithBurn.payTo( recipientAddress, LedgerValue.lovelace(totalWithdrawAmount.toLong) ) // Update campaign UTxO if partial withdrawal builderWithCampaignOutput = if isFullWithdrawal then builderWithRecipient else val nftValue = LedgerValue.asset(crowdfundingPolicyId, nftAsset, 1L) val newDatum = currentDatum.copy(withdrawn = newWithdrawn) builderWithRecipient.payTo( scriptAddress, LedgerValue.lovelace(2_000_000L) + nftValue, newDatum ) tx <- builderWithCampaignOutput .validFrom(Instant.ofEpochMilli(currentDatum.deadline.toLong + 1000)) .complete(provider, recipientAddress) .map(_.sign(signer).transaction) _ <- provider.submit(tx).map { case Right(_) => () case Left(error) => throw RuntimeException(s"Failed to submit: $error") } yield tx /** Reclaims funds from a failed campaign. * * After the deadline, if the goal is NOT reached, original donors can reclaim their donations. * Funds go back to the original donor identified in DonationDatum. * * @param campaignId * The campaign identifier * @param donationUtxos * Donation UTxOs to reclaim (at script address, contain tokens + ADA + DonationDatum) * @param signer * Transaction signer (for fee payment) * @return * The submitted transaction */ def reclaim( campaignId: ByteString, donationUtxos: Seq[Utxo], signer: TransactionSigner ): Future[Transaction] = given scala.concurrent.ExecutionContext = provider.executionContext for campaignUtxo <- findCampaignUtxo(campaignId).map( _.getOrElse(throw RuntimeException(s"No campaign found for id: $campaignId")) ) currentDatum = campaignUtxo.output.inlineDatum .getOrElse(throw IllegalStateException("Campaign UTxO must have inline datum")) .to[CampaignDatum] donationPolicyId = ScriptHash.fromByteString(currentDatum.donationPolicyId) donationScript = getDonationScript(campaignId) // Extract donor info from DonationDatum and full UTxO value // (donorAddress, donorPkh, datumAmount, utxoLovelace) donorInfos: Seq[(ShelleyAddress, PubKeyHash, BigInt, Long)] = donationUtxos.map { utxo => val donationDatum = utxo.output.inlineDatum .getOrElse( throw IllegalStateException("Donation UTxO must have inline datum") ) .to[DonationDatum] val donorAddress = addressFromPkh(donationDatum.donor) val utxoLovelace = utxo.output.value.coin.value (donorAddress, donationDatum.donor, donationDatum.amount, utxoLovelace) } // Use datum amount for tracking campaign state (matches on-chain logic) totalReclaimAmount: BigInt = donorInfos.map(_._3).foldLeft(BigInt(0))(_ + _) // Collect all unique donor key hashes for required signers donorKeyHashes: Set[AddrKeyHash] = donorInfos.map { case (_, pkh, _, _) => AddrKeyHash.fromByteString(pkh.hash) }.toSet // Calculate if this is full or partial reclaim newWithdrawn = currentDatum.withdrawn + totalReclaimAmount isFullReclaim = newWithdrawn == currentDatum.totalSum // Build burn map (all donation tokens have same fixed name) donationAsset = AssetName(DonationMintingPolicy.donationTokenName) totalTokensToBurn = donationUtxos.size.toLong burnMap = Map(donationAsset -> -totalTokensToBurn) nftAsset = AssetName(campaignId) // Helper to build Reclaim redeemer reclaimRedeemer = (tx: Transaction) => { val inputIdx = requireFound( tx.body.value.inputs.toSeq.indexOf(campaignUtxo.input), "Campaign input not found in transaction" ) val campaignOutputIdx = if isFullReclaim then -1 else requireFound( tx.body.value.outputs.indexWhere { sized => sized.value.address == scriptAddress && sized.value.value.assets.assets .get(crowdfundingPolicyId) .exists(_.get(nftAsset).exists(_ > 0)) }, "Campaign output not found for partial reclaim" ) // Create pairs of (donationInputIdx, reclaimerOutputIdx) and sort by input idx // to satisfy validator's strictly ascending requirement val sortedPairs = donationUtxos .zip(donorInfos) .map { case (utxo, (donorAddr, _, _, _)) => val donationIdx = BigInt( requireFound( tx.body.value.inputs.toSeq.indexOf(utxo.input), "Donation input not found in transaction" ) ) val reclaimerOutIdx = BigInt( requireFound( tx.body.value.outputs.indexWhere(_.value.address == donorAddr), s"Reclaimer output not found for donor" ) ) (donationIdx, reclaimerOutIdx) } .sortBy(_._1) val donationInputIndices = sortedPairs.map(_._1) val reclaimerOutputIndices = sortedPairs.map(_._2) Action .Reclaim( BigInt(inputIdx), BigInt(campaignOutputIdx), scalus.cardano.onchain.plutus.prelude.List.from(donationInputIndices), scalus.cardano.onchain.plutus.prelude.List.from(reclaimerOutputIndices) ) .toData } // Build transaction: spend campaign UTxO builderWithCampaign = TxBuilder(env) .spend( campaignUtxo, redeemerBuilder = reclaimRedeemer, crowdfundingContract ) .requireSignatures(donorKeyHashes) // Spend donation UTxOs (at script address) builderWithDonations = donationUtxos.foldLeft(builderWithCampaign) { (builder, utxo) => builder.spend(utxo, reclaimRedeemer, crowdfundingContract) } // Burn donation tokens builderWithBurn = builderWithDonations.mint(donationScript, burnMap, reclaimRedeemer) // Pay each donor their full UTxO value (includes min UTxO overhead) builderWithPayments = donorInfos.foldLeft(builderWithBurn) { case (builder, (donorAddr, _, _, utxoLovelace)) => builder.payTo(donorAddr, LedgerValue.lovelace(utxoLovelace)) } // Update campaign UTxO if partial reclaim builderWithCampaignOutput = if isFullReclaim then builderWithPayments else val nftValue = LedgerValue.asset(crowdfundingPolicyId, nftAsset, 1L) val newDatum = currentDatum.copy(withdrawn = newWithdrawn) builderWithPayments.payTo( scriptAddress, LedgerValue.lovelace(2_000_000L) + nftValue, newDatum ) // Use first donor address for fee payment feePayerAddress = donorInfos.headOption .map { case (addr, _, _, _) => addr } .getOrElse(throw RuntimeException("No donation UTxOs provided")) tx <- builderWithCampaignOutput .validFrom(Instant.ofEpochMilli(currentDatum.deadline.toLong + 1000)) .complete(provider, feePayerAddress) .map(_.sign(signer).transaction) _ <- provider.submit(tx).map { case Right(_) => () case Left(error) => throw RuntimeException(s"Failed to submit: $error") } yield tx /** Finds the campaign UTxO containing the campaign NFT with the given ID. */ def findCampaignUtxo( campaignId: ByteString ): Future[scala.Option[Utxo]] = given scala.concurrent.ExecutionContext = provider.executionContext for utxos <- provider .findUtxos(scriptAddress) .map(_.getOrElse(Map.empty)) yield val nftAsset = AssetName(campaignId) utxos .find { case (_, output) => output.value.assets.assets .get(crowdfundingPolicyId) .exists(_.get(nftAsset).exists(_ > 0)) } .map { case (input, output) => Utxo(input, output) } /** Finds all donation UTxOs at the script address for a campaign. * * Donation UTxOs are identified by: * - Being at script address * - NOT containing the campaign NFT * - Containing a donation token (from the campaign's donation policy) */ def findDonationUtxos(campaignId: ByteString): Future[Seq[Utxo]] = given scala.concurrent.ExecutionContext = provider.executionContext for utxos <- provider.findUtxos(scriptAddress).map(_.getOrElse(Map.empty)) campaignUtxo <- findCampaignUtxo(campaignId) currentDatum = campaignUtxo .flatMap(_.output.inlineDatum) .map(_.to[CampaignDatum]) yield val nftAsset = AssetName(campaignId) val donationPolicyId = currentDatum .map(d => ScriptHash.fromByteString(d.donationPolicyId)) .getOrElse(throw RuntimeException("Campaign not found")) utxos .filterNot { case (_, output) => // Exclude campaign UTxO (has NFT) output.value.assets.assets .get(crowdfundingPolicyId) .exists(_.get(nftAsset).exists(_ > 0)) } .filter { case (_, output) => // Include only UTxOs with donation tokens output.value.assets.assets .get(donationPolicyId) .exists(_.nonEmpty) } .map { case (input, output) => Utxo(input, output) } .toSeq } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/crowdfunding/CrowdfundingEmulatorTest.scala ```scala package scalus.examples.crowdfunding import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.ByteString import scalus.uplc.builtin.Data.toData import scalus.cardano.address.{ShelleyAddress, ShelleyPaymentPart} import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.* import scalus.cardano.node.{BlockchainProvider, Emulator} import scalus.cardano.txbuilder.TransactionSigner import scalus.cardano.onchain.plutus.v1.{PosixTime, PubKeyHash} import scalus.testing.kit.Party.{Alice, Bob, Charles} import scalus.testing.kit.TestUtil.genesisHash import scalus.testing.kit.ScalusTest import scalus.utils.await import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.ExecutionContext.Implicits.global /** Integration tests for crowdfunding contract using Emulator. * * Tests the full lifecycle: create campaign, donate, withdraw (success), reclaim (failure). */ class CrowdfundingEmulatorTest extends AnyFunSuite, ScalusTest { import CrowdfundingEmulatorTest.* test("recipient can create campaign") { TestCase( action = TestAction.Create, expected = Expected.Success ).run() } test("donor can donate to campaign before deadline") { TestCase( action = TestAction.Donate(amount = 5_000_000L), expected = Expected.Success ).run() } test("multiple donors can donate") { TestCase( action = TestAction.MultipleDonations, expected = Expected.Success ).run() } test("recipient can withdraw after deadline when goal reached") { TestCase( action = TestAction.WithdrawSuccess, expected = Expected.Success ).run() } test("donor can reclaim after deadline when goal not reached") { TestCase( action = TestAction.ReclaimSuccess, expected = Expected.Success ).run() } test("reclaim rejects duplicate donation indices (double-spend prevention)") { TestCase( action = TestAction.ReclaimDuplicateIndices, expected = Expected.Failure("script evaluation failed") // Validator rejects duplicate indices ).run() } } object CrowdfundingEmulatorTest extends ScalusTest { import scalus.cardano.address.Network private val crowdfundingContract = CrowdfundingContract.compiled.withErrorTraces private val donationMintingContract = DonationMintingContract.compiled.withErrorTraces // Party to role mapping private val recipientParty = Alice private val donor1Party = Bob private val donor2Party = Charles private val recipientAddress: ShelleyAddress = recipientParty.address(Network.Mainnet) private val donor1Address: ShelleyAddress = donor1Party.address(Network.Mainnet) private val donor2Address: ShelleyAddress = donor2Party.address(Network.Mainnet) private val goal = 10_000_000L // 10 ADA goal private val initialCampaignValue = Coin(2_000_000L) // Min UTxO private val slot: SlotNo = 100 private val beforeSlot: SlotNo = slot - 10 private val afterSlot: SlotNo = slot + 10 // deadline computed per-test from provider.cardanoInfo.slotConfig enum TestAction: case Create case Donate(amount: Long) case MultipleDonations case WithdrawSuccess case ReclaimSuccess case ReclaimDuplicateIndices enum Expected: case Success case Failure(errorContains: String) private def getDeadline(provider: BlockchainProvider): PosixTime = BigInt(provider.cardanoInfo.slotConfig.slotToTime(slot)) case class TestCase( action: TestAction, expected: Expected ): def run(): Unit = val provider = createProvider() val endpoints = CrowdfundingEndpoints( provider, crowdfundingContract, donationMintingContract ) action match case TestAction.Create => runCreateTest(provider, endpoints) case TestAction.Donate(amount) => runDonateTest(provider, endpoints, amount) case TestAction.MultipleDonations => runMultipleDonationsTest(provider, endpoints) case TestAction.WithdrawSuccess => runWithdrawSuccessTest(provider, endpoints) case TestAction.ReclaimSuccess => runReclaimSuccessTest(provider, endpoints) case TestAction.ReclaimDuplicateIndices => runReclaimDuplicateIndicesTest(provider, endpoints) private def runCreateTest( provider: Emulator, endpoints: CrowdfundingEndpoints ): Unit = provider.setSlot(beforeSlot) val result = scala.util.Try { endpoints .createCampaign( recipientAddress = recipientAddress, goal = goal, deadline = getDeadline(provider).toLong, initialValue = initialCampaignValue, signer = recipientParty.signer ) .await() } verifyResult(result.map(_._1)) private def runDonateTest( provider: Emulator, endpoints: CrowdfundingEndpoints, amount: Long ): Unit = // First create the campaign provider.setSlot(beforeSlot) val (_, campaignId) = endpoints .createCampaign( recipientAddress = recipientAddress, goal = goal, deadline = getDeadline(provider).toLong, initialValue = initialCampaignValue, signer = recipientParty.signer ) .await() // Then donate val result = scala.util.Try { endpoints .donate( campaignId = campaignId, donorAddress = donor1Address, amount = amount, signer = donor1Party.signer ) .await() } verifyResult(result) private def runMultipleDonationsTest( provider: Emulator, endpoints: CrowdfundingEndpoints ): Unit = // Create campaign provider.setSlot(beforeSlot) val (_, campaignId) = endpoints .createCampaign( recipientAddress = recipientAddress, goal = goal, deadline = getDeadline(provider).toLong, initialValue = initialCampaignValue, signer = recipientParty.signer ) .await() // First donation endpoints .donate( campaignId = campaignId, donorAddress = donor1Address, amount = 3_000_000L, signer = donor1Party.signer ) .await() // Second donation val result = scala.util.Try { endpoints .donate( campaignId = campaignId, donorAddress = donor2Address, amount = 4_000_000L, signer = donor2Party.signer ) .await() } verifyResult(result) private def runWithdrawSuccessTest( provider: Emulator, endpoints: CrowdfundingEndpoints ): Unit = // Create campaign provider.setSlot(beforeSlot) val (_, campaignId) = endpoints .createCampaign( recipientAddress = recipientAddress, goal = goal, deadline = getDeadline(provider).toLong, initialValue = initialCampaignValue, signer = recipientParty.signer ) .await() // Donate enough to reach goal endpoints .donate( campaignId = campaignId, donorAddress = donor1Address, amount = 6_000_000L, signer = donor1Party.signer ) .await() endpoints .donate( campaignId = campaignId, donorAddress = donor2Address, amount = 5_000_000L, signer = donor2Party.signer ) .await() // Move past deadline provider.setSlot(afterSlot) // Find donation UTxOs at script address (unified design: tokens + ADA in same UTxO) val donationUtxos = endpoints.findDonationUtxos(campaignId).await() // Withdraw - only recipient needs to sign (tokens are at script address) val result = scala.util.Try { endpoints .withdraw( campaignId = campaignId, recipientAddress = recipientAddress, donationUtxos = donationUtxos, signer = recipientParty.signer ) .await() } verifyResult(result) private def runReclaimSuccessTest( provider: Emulator, endpoints: CrowdfundingEndpoints ): Unit = // Create campaign provider.setSlot(beforeSlot) val (_, campaignId) = endpoints .createCampaign( recipientAddress = recipientAddress, goal = goal, deadline = getDeadline(provider).toLong, initialValue = initialCampaignValue, signer = recipientParty.signer ) .await() // Donate less than goal endpoints .donate( campaignId = campaignId, donorAddress = donor1Address, amount = 3_000_000L, signer = donor1Party.signer ) .await() // Move past deadline provider.setSlot(afterSlot) // Find donation UTxOs at script address (unified design: tokens + ADA in same UTxO) val donationUtxos = endpoints.findDonationUtxos(campaignId).await() // Reclaim - donor identified from DonationDatum, funds go back to original donor val result = scala.util.Try { endpoints .reclaim( campaignId = campaignId, donationUtxos = donationUtxos, signer = donor1Party.signer ) .await() } verifyResult(result) private def runReclaimDuplicateIndicesTest( provider: Emulator, endpoints: CrowdfundingEndpoints ): Unit = // Create campaign provider.setSlot(beforeSlot) val (_, campaignId) = endpoints .createCampaign( recipientAddress = recipientAddress, goal = goal, deadline = getDeadline(provider).toLong, initialValue = initialCampaignValue, signer = recipientParty.signer ) .await() // Donate less than goal endpoints .donate( campaignId = campaignId, donorAddress = donor1Address, amount = 3_000_000L, signer = donor1Party.signer ) .await() // Move past deadline provider.setSlot(afterSlot) // Find donation UTxOs val donationUtxos = endpoints.findDonationUtxos(campaignId).await() // Build malicious transaction with duplicate indices val result = scala.util.Try { buildMaliciousReclaimTx( provider, endpoints, campaignId, donationUtxos, donor1Party.signer ).await() } verifyResult(result) /** Build a malicious reclaim transaction with duplicate donation indices. * * This simulates an attacker trying to double-claim a donation. */ private def buildMaliciousReclaimTx( provider: Emulator, endpoints: CrowdfundingEndpoints, campaignId: ByteString, donationUtxos: Seq[Utxo], signer: TransactionSigner )(using ExecutionContext): Future[Transaction] = import scalus.cardano.txbuilder.TxBuilder import scalus.uplc.builtin.Data.toData import java.time.Instant for campaignUtxo <- endpoints.findCampaignUtxo(campaignId).map(_.get) currentDatum = campaignUtxo.output.requireInlineDatum.to[CampaignDatum] donationPolicyId = ScriptHash.fromByteString(currentDatum.donationPolicyId) donationScript = getDonationScript(endpoints, campaignId) // Build burn map burnMap = donationUtxos .flatMap { utxo => utxo.output.value.assets.assets .getOrElse(donationPolicyId, Map.empty) .map { case (name, qty) => (name, -qty.toLong) } } .groupBy(_._1) .map { case (name, pairs) => (name, pairs.map(_._2).sum) } nftAsset = AssetName(campaignId) crowdfundingPolicyId = crowdfundingContract.script.scriptHash // MALICIOUS: Use duplicate indices to try double-claiming maliciousRedeemer = (tx: Transaction) => { val inputIdx = tx.body.value.inputs.toSeq.indexOf(campaignUtxo.input) val donationIdx = donationUtxos.headOption .map(u => tx.body.value.inputs.toSeq.indexOf(u.input)) .getOrElse(1) // Attack: same index twice! val duplicateIndices = scalus.cardano.onchain.plutus.prelude .List(BigInt(donationIdx), BigInt(donationIdx)) val outputIndices = scalus.cardano.onchain.plutus.prelude.List(BigInt(0), BigInt(1)) Action .Reclaim( BigInt(inputIdx), BigInt(-1), duplicateIndices, // DUPLICATE! outputIndices ) .toData } donorPkh = extractDonorPkh(donor1Address) donorKeyHash = AddrKeyHash.fromByteString(donorPkh.hash) // Build the malicious transaction builderWithCampaign = TxBuilder(provider.cardanoInfo) .spend( campaignUtxo, redeemerBuilder = maliciousRedeemer, crowdfundingContract.script ) .requireSignature(donorKeyHash) builderWithDonations = donationUtxos.foldLeft(builderWithCampaign) { (builder, utxo) => builder.spend( utxo, maliciousRedeemer, crowdfundingContract.script ) } builderWithBurn = builderWithDonations.mint( donationScript, burnMap, maliciousRedeemer ) // Two outputs to try double-claiming amount = 3_000_000L builderWithPayments = builderWithBurn .payTo(donor1Address, Value.lovelace(amount)) .payTo(donor1Address, Value.lovelace(amount)) // Second output for double-claim tx <- builderWithPayments .validFrom(Instant.ofEpochMilli(currentDatum.deadline.toLong + 1000)) .complete(provider, donor1Address) .map(_.sign(signer).transaction) _ <- provider.submit(tx).map { case Right(_) => () case Left(error) => throw RuntimeException(s"Failed to submit: $error") } yield tx private def getDonationScript( endpoints: CrowdfundingEndpoints, campaignId: ByteString ): Script.PlutusV3 = val appliedProgram = donationMintingContract.program $ campaignId.toData Script.PlutusV3(appliedProgram.cborByteString) private def extractDonorPkh(address: ShelleyAddress): PubKeyHash = address.payment match case ShelleyPaymentPart.Key(hash) => PubKeyHash(hash) case _ => throw IllegalArgumentException("Expected key payment") private def verifyResult(result: scala.util.Try[Transaction]): Unit = expected match case Expected.Success => assert( result.isSuccess, s"Should succeed but failed: ${result.failed.getOrElse("unknown")}" ) case Expected.Failure(errorContains) => assert(result.isFailure, "Should fail but succeeded") val errorMsg = result.failed.get.getMessage assert( errorMsg.contains(errorContains), s"Expected error '$errorContains' but got '$errorMsg'" ) private def createProvider(): Emulator = // Each party gets multiple UTxOs - one for spending/fees, one for collateral Emulator( initialUtxos = Map( // Recipient UTxOs Input(genesisHash, 0) -> TransactionOutput.Babbage( address = recipientAddress, value = Value.lovelace(50_000_000L) ), Input(genesisHash, 1) -> TransactionOutput.Babbage( address = recipientAddress, value = Value.lovelace(50_000_000L) ), // Donor1 UTxOs Input(genesisHash, 2) -> TransactionOutput.Babbage( address = donor1Address, value = Value.lovelace(50_000_000L) ), Input(genesisHash, 3) -> TransactionOutput.Babbage( address = donor1Address, value = Value.lovelace(50_000_000L) ), // Donor2 UTxOs Input(genesisHash, 4) -> TransactionOutput.Babbage( address = donor2Address, value = Value.lovelace(50_000_000L) ), Input(genesisHash, 5) -> TransactionOutput.Babbage( address = donor2Address, value = Value.lovelace(50_000_000L) ) ), initialContext = Context.testMainnet(), mutators = Set(PlutusScriptsTransactionMutator) ) } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/crowdfunding/CrowdfundingScalaCheckCommandTest.scala ```scala package scalus.examples.crowdfunding import org.scalacheck.Prop import org.scalatest.funsuite.AnyFunSuite import scalus.cardano.address.{Network, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart} import scalus.cardano.ledger.* import scalus.cardano.node.{BlockchainReader, Emulator} import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.cardano.txbuilder.{TransactionSigner, TxBuilder} import scalus.cardano.wallet.hd.{HdAccount, HdKeyPair} import scalus.crypto.ed25519.given import scalus.cardano.txbuilder.TxBuilderException import scalus.testing.* import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{ByteString, Data} import java.time.Instant import scala.concurrent.duration.Duration import scala.concurrent.{Await, ExecutionContext, Future} /** ScalaCheck Commands property-based test for crowdfunding contract with many participants. * * Creates 200 participants (1 recipient + 199 donors) and uses ContractScalaCheckCommands to * generate random sequences of actions (donate, wait, withdraw, reclaim), verifying invariants * hold after each successful transaction. */ class CrowdfundingScalaCheckCommandTest extends AnyFunSuite { import CrowdfundingScalaCheckCommandTest.* private given ExecutionContext = ExecutionContext.global test("crowdfunding: invariants hold under random action sequences with many participants") { val (emulator, campaignId) = createEmulatorWithCampaign() val step = makeCrowdfundingStep(campaignId) val commands = ContractScalaCheckCommands(emulator, step) { (reader, state) => Future.successful { state.datum match case Some(d) => Prop(d.totalSum >= 0) :| "totalSum non-negative" && Prop(d.goal == BigInt(goal)) :| "goal unchanged" && Prop(d.withdrawn >= 0) :| "withdrawn non-negative" && Prop(d.withdrawn <= d.totalSum) :| "withdrawn <= totalSum" case None => Prop.passed // campaign fully consumed } } val result = org.scalacheck.Test.check( org.scalacheck.Test.Parameters.default .withMinSuccessfulTests(10) .withMaxDiscardRatio(20), commands.property() ) assert(result.passed, s"Property test failed: $result") } } object CrowdfundingScalaCheckCommandTest { private val crowdfundingContract = CrowdfundingContract.compiled.withErrorTraces private val donationMintingContract = DonationMintingContract.compiled.withErrorTraces private val crowdfundingScript = crowdfundingContract.script private val crowdfundingPolicyId = crowdfundingScript.scriptHash private val network = Network.Mainnet private val goal = 5_000_000L private val donationAmount = 200_000L private val deadlineSlot: Long = 100L private val beforeDeadlineSlot: Long = deadlineSlot - 10 private val donorsPerStep = 5 private val maxDonationsPerBatch = 10 private val scriptAddress = crowdfundingContract.address(network) // ========================================================================= // Participants (200 total: 1 recipient + 199 donors) // ========================================================================= case class Participant(index: Int, account: HdAccount) { lazy val addrKeyHash: AddrKeyHash = account.paymentKeyHash val address: ShelleyAddress = ShelleyAddress( network, ShelleyPaymentPart.Key(account.paymentKeyHash), ShelleyDelegationPart.Null ) lazy val signer: TransactionSigner = new TransactionSigner(Set(account.paymentKeyPair)) } private val mnemonic: String = "test test test test test test test test test test test test " + "test test test test test test test test test test test sauce" private val numDonors = 199 // Derive master key once, then derive all accounts efficiently private val participants: IndexedSeq[Participant] = { val masterKey = HdKeyPair.masterFromMnemonic(mnemonic, "") val purposeKey = masterKey.deriveHardened(1852) val coinTypeKey = purposeKey.deriveHardened(1815) (0 to numDonors).map { i => val accountKey = coinTypeKey.deriveHardened(i) Participant(i, new HdAccount(i, accountKey)) } } private val recipientP = participants(0) private val donors: IndexedSeq[Participant] = participants.drop(1) // Lookup: PubKeyHash.hash -> Participant (for finding signing keys during reclaim) private val participantByPkhHash: Map[ByteString, Participant] = participants.map { p => extractPkh(p.address).hash -> p }.toMap // ========================================================================= // State // ========================================================================= case class CrowdfundingState( campaignId: ByteString, campaignUtxo: Option[Utxo], datum: Option[CampaignDatum], donationUtxos: Seq[Utxo] ) // ========================================================================= // Actors // ========================================================================= /** Actor that donates to the campaign before the deadline. Selects a rotating subset of donors * based on current donation count. */ class DonorGroupActor(allDonors: IndexedSeq[Participant], perStep: Int) extends ContractTestActor[CrowdfundingState] { override def name: String = "donor-group" override def actions(reader: BlockchainReader, state: CrowdfundingState)(using ExecutionContext ): Future[Seq[StepAction]] = state.datum match case None => Future.successful(Seq.empty) case Some(d) => reader.currentSlot.flatMap { currentSlot => val slotTime = reader.cardanoInfo.slotConfig.slotToTime(currentSlot) if slotTime < d.deadline.toLong && state.campaignUtxo.isDefined then val offset = state.donationUtxos.size val selected = (0 until perStep).map { i => allDonors((offset + i * 37) % allDonors.size) } val txFutures = selected.map { donor => buildDonateTx(reader, state, donor) .map(tx => Some(StepAction.Submit(tx))) .recover { case _: TxBuilderException => None } } Future.sequence(txFutures).map(_.flatten) else Future.successful(Seq.empty) } } /** Actor that withdraws funds after deadline when goal is reached. */ class WithdrawActor(recipient: Participant) extends ContractTestActor[CrowdfundingState] { override def name: String = s"withdraw-${recipient.index}" override def actions(reader: BlockchainReader, state: CrowdfundingState)(using ExecutionContext ): Future[Seq[StepAction]] = state.datum match case None => Future.successful(Seq.empty) case Some(d) => reader.currentSlot.flatMap { currentSlot => val slotTime = reader.cardanoInfo.slotConfig.slotToTime(currentSlot) val afterDeadline = slotTime > d.deadline.toLong val goalReached = d.totalSum >= d.goal val hasDonations = state.donationUtxos.nonEmpty if afterDeadline && goalReached && hasDonations then buildWithdrawTx(reader, state) .map(tx => Seq(StepAction.Submit(tx))) .recover { case _: TxBuilderException => Seq.empty } else Future.successful(Seq.empty) } } /** Actor that reclaims donations after deadline when goal is not reached. */ class ReclaimActor(participantByPkhHash: Map[ByteString, Participant]) extends ContractTestActor[CrowdfundingState] { override def name: String = "reclaim" override def actions(reader: BlockchainReader, state: CrowdfundingState)(using ExecutionContext ): Future[Seq[StepAction]] = state.datum match case None => Future.successful(Seq.empty) case Some(d) => reader.currentSlot.flatMap { currentSlot => val slotTime = reader.cardanoInfo.slotConfig.slotToTime(currentSlot) val afterDeadline = slotTime > d.deadline.toLong val goalReached = d.totalSum >= d.goal val hasDonations = state.donationUtxos.nonEmpty if afterDeadline && !goalReached && hasDonations then buildReclaimTx(reader, state) .map(tx => Seq(StepAction.Submit(tx))) .recover { case _: TxBuilderException => Seq.empty } else Future.successful(Seq.empty) } } // ========================================================================= // Step (built from actors) // ========================================================================= private def makeCrowdfundingStep( campaignId: ByteString ): ContractStepVariations[CrowdfundingState] = { val actors: Seq[ContractTestActor[CrowdfundingState]] = Seq( new DonorGroupActor(donors, donorsPerStep), new WithdrawActor(recipientP), new ReclaimActor(participantByPkhHash) ) ContractStepVariations.fromActors[CrowdfundingState]( extract = reader => for campaignUtxo <- findCampaignUtxo(reader, campaignId) datum = campaignUtxo.flatMap(_.output.inlineDatum.map(_.to[CampaignDatum])) donationUtxos <- datum match case Some(d) => findDonationUtxos( reader, campaignId, ScriptHash.fromByteString(d.donationPolicyId) ) case None => Future.successful(Seq.empty) yield CrowdfundingState(campaignId, campaignUtxo, datum, donationUtxos), actors = actors, delays = _ => Seq(20L, 50L) ) } // ========================================================================= // Helpers // ========================================================================= private def extractPkh(address: ShelleyAddress): PubKeyHash = address.payment match case ShelleyPaymentPart.Key(hash) => PubKeyHash(hash) case _ => throw IllegalArgumentException("Expected key payment credential") private def addressFromPkh(pkh: PubKeyHash): ShelleyAddress = ShelleyAddress( network, ShelleyPaymentPart.Key(AddrKeyHash.fromByteString(pkh.hash)), ShelleyDelegationPart.Null ) private def getDonationScript(campaignId: ByteString): Script.PlutusV3 = { val appliedProgram = donationMintingContract.program $ campaignId.toData Script.PlutusV3(appliedProgram.cborByteString) } private def hasCampaignNft(output: TransactionOutput, nftAsset: AssetName): Boolean = output.value.assets.assets .get(crowdfundingPolicyId) .exists(_.get(nftAsset).exists(_ > 0)) private def findCampaignOutputIdx(tx: Transaction, nftAsset: AssetName): Int = tx.body.value.outputs.indexWhere(sized => hasCampaignNft(sized.value, nftAsset)) private def findCampaignUtxo( reader: BlockchainReader, campaignId: ByteString )(using ExecutionContext): Future[Option[Utxo]] = { val nftAsset = AssetName(campaignId) reader.findUtxos(scriptAddress).map(_.getOrElse(Map.empty)).map { utxos => utxos .find((_, output) => hasCampaignNft(output, nftAsset)) .map((input, output) => Utxo(input, output)) } } private def findDonationUtxos( reader: BlockchainReader, campaignId: ByteString, donationPolicyId: ScriptHash )(using ExecutionContext): Future[Seq[Utxo]] = { val nftAsset = AssetName(campaignId) reader.findUtxos(scriptAddress).map(_.getOrElse(Map.empty)).map { utxos => utxos .filterNot((_, output) => hasCampaignNft(output, nftAsset)) .filter { case (_, output) => output.value.assets.assets .get(donationPolicyId) .exists(_.nonEmpty) } .map((input, output) => Utxo(input, output)) .toSeq } } // ========================================================================= // Transaction builders // ========================================================================= private def buildDonateTx( reader: BlockchainReader, state: CrowdfundingState, donor: Participant )(using ExecutionContext): Future[Transaction] = { val campaignUtxo = state.campaignUtxo.getOrElse(throw RuntimeException("No campaign")) val currentDatum = state.datum.getOrElse(throw RuntimeException("No datum")) val donationPolicyId = ScriptHash.fromByteString(currentDatum.donationPolicyId) val donationScript = getDonationScript(state.campaignId) val newDatum = CampaignDatum( totalSum = currentDatum.totalSum + BigInt(donationAmount), goal = currentDatum.goal, recipient = currentDatum.recipient, deadline = currentDatum.deadline, withdrawn = currentDatum.withdrawn, donationPolicyId = currentDatum.donationPolicyId ) val donationAsset = AssetName(DonationMintingPolicy.donationTokenName) val donationTokenValue = Value.asset(donationPolicyId, donationAsset, 1L) val nftAsset = AssetName(state.campaignId) val nftValue = Value.asset(crowdfundingPolicyId, nftAsset, 1L) val newCampaignValue = Value.lovelace(campaignUtxo.output.value.coin.value + donationAmount) + nftValue val donationUtxoValue = Value.lovelace(donationAmount) + donationTokenValue val donorPkh = extractPkh(donor.address) val donationDatum = DonationDatum(donorPkh, BigInt(donationAmount)) val donateRedeemer: Transaction => Data = { (tx: Transaction) => val inputIdx = tx.body.value.inputs.toSeq.indexOf(campaignUtxo.input) val campaignOutputIdx = findCampaignOutputIdx(tx, nftAsset) val donationOutputIdx = tx.body.value.outputs.indexWhere { sized => sized.value.address == scriptAddress && !hasCampaignNft(sized.value, nftAsset) } Action .Donate( BigInt(donationAmount), BigInt(inputIdx), BigInt(campaignOutputIdx), BigInt(donationOutputIdx) ) .toData } TxBuilder(reader.cardanoInfo) .spend(campaignUtxo, donateRedeemer, crowdfundingScript) .mint(donationScript, Map(donationAsset -> 1L), donateRedeemer) .payTo(scriptAddress, newCampaignValue, newDatum) .payTo(scriptAddress, donationUtxoValue, donationDatum) .validTo(Instant.ofEpochMilli(currentDatum.deadline.toLong - 1000)) .complete(reader, donor.address) .map(_.sign(donor.signer).transaction) } private def buildWithdrawTx( reader: BlockchainReader, state: CrowdfundingState )(using ExecutionContext): Future[Transaction] = { val campaignUtxo = state.campaignUtxo.getOrElse(throw RuntimeException("No campaign")) val currentDatum = state.datum.getOrElse(throw RuntimeException("No datum")) val donationUtxos = state.donationUtxos.take(maxDonationsPerBatch) val recipientPkh = currentDatum.recipient val recipientKeyHash = AddrKeyHash.fromByteString(recipientPkh.hash) val recipientAddress = addressFromPkh(recipientPkh) val donationScript = getDonationScript(state.campaignId) val totalWithdrawAmount: BigInt = donationUtxos .map(_.output.requireInlineDatum.to[DonationDatum].amount) .sum val newWithdrawn = currentDatum.withdrawn + totalWithdrawAmount val isFullWithdrawal = newWithdrawn == currentDatum.totalSum val donationAsset = AssetName(DonationMintingPolicy.donationTokenName) val burnMap = Map(donationAsset -> -donationUtxos.size.toLong) val nftAsset = AssetName(state.campaignId) val withdrawRedeemer: Transaction => Data = { (tx: Transaction) => val inputIdx = tx.body.value.inputs.toSeq.indexOf(campaignUtxo.input) val campaignOutputIdx = if isFullWithdrawal then -1 else findCampaignOutputIdx(tx, nftAsset) val recipientOutputIdx = tx.body.value.outputs.indexWhere { sized => sized.value.address == recipientAddress } val donationInputIndices = donationUtxos .map(u => BigInt(tx.body.value.inputs.toSeq.indexOf(u.input))) .sorted Action .Withdraw( BigInt(inputIdx), BigInt(campaignOutputIdx), BigInt(recipientOutputIdx), scalus.cardano.onchain.plutus.prelude.List.from(donationInputIndices) ) .toData } val withRecipient = donationUtxos .foldLeft( TxBuilder(reader.cardanoInfo) .spend( campaignUtxo, withdrawRedeemer, crowdfundingScript ) .requireSignature(recipientKeyHash) ) { (b, utxo) => b.spend(utxo, withdrawRedeemer, crowdfundingScript) } .mint(donationScript, burnMap, withdrawRedeemer) .payTo(recipientAddress, Value.lovelace(totalWithdrawAmount.toLong)) val finalBuilder = if isFullWithdrawal then withRecipient else val nftVal = Value.asset(crowdfundingPolicyId, nftAsset, 1L) val newDatum = CampaignDatum( totalSum = currentDatum.totalSum, goal = currentDatum.goal, recipient = currentDatum.recipient, deadline = currentDatum.deadline, withdrawn = newWithdrawn, donationPolicyId = currentDatum.donationPolicyId ) withRecipient.payTo( scriptAddress, Value.lovelace(2_000_000L) + nftVal, newDatum ) finalBuilder .validFrom(Instant.ofEpochMilli(currentDatum.deadline.toLong + 1000)) .complete(reader, recipientAddress) .map(_.sign(recipientP.signer).transaction) } private def buildReclaimTx( reader: BlockchainReader, state: CrowdfundingState )(using ExecutionContext): Future[Transaction] = { val campaignUtxo = state.campaignUtxo.getOrElse(throw RuntimeException("No campaign")) val currentDatum = state.datum.getOrElse(throw RuntimeException("No datum")) val donationUtxos = state.donationUtxos.take(maxDonationsPerBatch) val donationScript = getDonationScript(state.campaignId) val donorInfos = donationUtxos.map { utxo => val dd = utxo.output.requireInlineDatum.to[DonationDatum] val addr = addressFromPkh(dd.donor) val participant = participantByPkhHash(dd.donor.hash) (addr, dd.donor, dd.amount, utxo.output.value.coin.value, participant) } val totalReclaimAmount: BigInt = donorInfos.map(_._3).sum val newWithdrawn = currentDatum.withdrawn + totalReclaimAmount val isFullReclaim = newWithdrawn == currentDatum.totalSum val donorKeyHashes = donorInfos.map { case (_, pkh, _, _, _) => AddrKeyHash.fromByteString(pkh.hash) }.toSet val donationAsset = AssetName(DonationMintingPolicy.donationTokenName) val burnMap = Map(donationAsset -> -donationUtxos.size.toLong) val nftAsset = AssetName(state.campaignId) val reclaimRedeemer: Transaction => Data = { (tx: Transaction) => val inputIdx = tx.body.value.inputs.toSeq.indexOf(campaignUtxo.input) val campaignOutputIdx = if isFullReclaim then -1 else findCampaignOutputIdx(tx, nftAsset) val sortedPairs = donationUtxos .zip(donorInfos) .map { case (utxo, (donorAddr, _, _, _, _)) => val donationIdx = BigInt(tx.body.value.inputs.toSeq.indexOf(utxo.input)) val reclaimerOutIdx = BigInt(tx.body.value.outputs.indexWhere(_.value.address == donorAddr)) (donationIdx, reclaimerOutIdx) } .sortBy(_._1) Action .Reclaim( BigInt(inputIdx), BigInt(campaignOutputIdx), scalus.cardano.onchain.plutus.prelude.List.from(sortedPairs.map(_._1)), scalus.cardano.onchain.plutus.prelude.List.from(sortedPairs.map(_._2)) ) .toData } val withPayments = donorInfos.foldLeft( donationUtxos .foldLeft( TxBuilder(reader.cardanoInfo) .spend(campaignUtxo, reclaimRedeemer, crowdfundingScript) .requireSignatures(donorKeyHashes) ) { (b, utxo) => b.spend(utxo, reclaimRedeemer, crowdfundingScript) } .mint(donationScript, burnMap, reclaimRedeemer) ) { case (b, (donorAddr, _, _, utxoLovelace, _)) => b.payTo(donorAddr, Value.lovelace(utxoLovelace)) } val finalBuilder = if isFullReclaim then withPayments else val nftVal = Value.asset(crowdfundingPolicyId, nftAsset, 1L) val newDatum = CampaignDatum( totalSum = currentDatum.totalSum, goal = currentDatum.goal, recipient = currentDatum.recipient, deadline = currentDatum.deadline, withdrawn = newWithdrawn, donationPolicyId = currentDatum.donationPolicyId ) withPayments.payTo( scriptAddress, Value.lovelace(2_000_000L) + nftVal, newDatum ) val feePayerAddr = donorInfos.head._1 val allSigners = new TransactionSigner( donorInfos.map(_._5.account.paymentKeyPair).toSet ) finalBuilder .validFrom(Instant.ofEpochMilli(currentDatum.deadline.toLong + 1000)) .complete(reader, feePayerAddr) .map(_.sign(allSigners).transaction) } // ========================================================================= // Setup // ========================================================================= private def createEmulatorWithCampaign(): (Emulator, ByteString) = { given ExecutionContext = ExecutionContext.global // 2 UTxOs per participant for spending + collateral val addresses = participants.flatMap(p => Seq(p.address, p.address)) val emulator = Emulator.withAddresses(addresses, Value.lovelace(50_000_000L)) emulator.setSlot(beforeDeadlineSlot) val deadline = emulator.cardanoInfo.slotConfig.slotToTime(deadlineSlot) val recipientPkh = extractPkh(recipientP.address) val recipientKeyHash = AddrKeyHash.fromByteString(recipientPkh.hash) val utxos = Await.result( emulator.findUtxos(recipientP.address).map(_.getOrElse(Map.empty)), Duration.Inf ) val firstUtxo = utxos.head val seedUtxo = Utxo(firstUtxo._1, firstUtxo._2) val txOutRef = scalus.cardano.onchain.plutus.v3.TxOutRef( scalus.cardano.onchain.plutus.v3.TxId(firstUtxo._1.transactionId), firstUtxo._1.index ) val campaignId = scalus.uplc.builtin.Builtins.blake2b_256( scalus.uplc.builtin.Builtins.serialiseData(txOutRef.toData) ) val donationPolicyId = getDonationScript(campaignId).scriptHash val datum = CampaignDatum( totalSum = BigInt(0), goal = BigInt(goal), recipient = recipientPkh, deadline = BigInt(deadline), withdrawn = BigInt(0), donationPolicyId = donationPolicyId ) val redeemer: Action = Action.Create( goal = BigInt(goal), recipient = recipientPkh, deadline = BigInt(deadline) ) val nftAsset = AssetName(campaignId) val mintedValue = Value.asset(crowdfundingPolicyId, nftAsset, 1L) val tx = Await.result( TxBuilder(emulator.cardanoInfo) .spend(seedUtxo) .mint(crowdfundingScript, Map(nftAsset -> 1L), redeemer) .requireSignature(recipientKeyHash) .payTo(scriptAddress, Value(Coin(2_000_000L)) + mintedValue, datum) .validTo(Instant.ofEpochMilli(deadline - 1000)) .complete(emulator, recipientP.address) .map(_.sign(recipientP.signer).transaction), Duration.Inf ) val submitResult = Await.result(emulator.submit(tx), Duration.Inf) assert(submitResult.isRight, s"Campaign creation failed: $submitResult") (emulator, campaignId) } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/crowdfunding/CrowdfundingScenarioTest.scala ```scala package scalus.examples.crowdfunding import cps.* import org.scalatest.funsuite.AnyFunSuite import scalus.cardano.address.{Network, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart} import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.* import scalus.cardano.node.{BlockchainReader, Emulator} import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.cardano.txbuilder.{TransactionSigner, TxBuilder} import scalus.testing.* import scalus.testing.kit.Party import scalus.testing.kit.Party.{Alice, Bob, Charles} import scalus.testing.kit.TestUtil.genesisHash import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{ByteString, Data} import java.time.Instant import scala.concurrent.{Await, Future} import scala.concurrent.duration.Duration import scala.util.{Failure, Success} /** Scenario exploration test for crowdfunding contract. * * Uses non-deterministic branching to explore different action sequences (donate, wait, withdraw, * reclaim) and verify invariants and access control hold across all paths. */ class CrowdfundingScenarioTest extends AnyFunSuite { import CrowdfundingScenarioTest.* import Scenario.futureToScenarioConversion // Lowering of the prelude's `===` macro-spliced application trips the // not-provably-default Eq heuristic; the warning is a known false positive here. private given scalus.compiler.Options = scalus.compiler.Options.default.copy(noWarn = true) test("explore: campaign invariants hold under non-deterministic actions") { val emulator = createEmulator() val scenario = async[Scenario] { val campaignId = createCampaignS().await Scenario .explore(maxDepth = 3) { _ => async[Scenario] { val action = Scenario .choices( "donate_bob", "donate_charles", "wait", "withdraw", "reclaim" ) .await action match case "donate_bob" => tryDonateS(campaignId, donor1Addr, 3_000_000L, donor1).await case "donate_charles" => tryDonateS(campaignId, donor2Addr, 4_000_000L, donor2).await case "wait" => Scenario.sleep(20).await case "withdraw" => tryWithdrawS(campaignId).await case "reclaim" => tryReclaimS(campaignId).await // Check datum invariants val reader = Scenario.snapshotReader.await val maybeDatum = tryGetCampaignDatum(reader, campaignId).await maybeDatum match case Some(datum) => Scenario .check( datum.totalSum >= 0, "totalSum must be non-negative" ) .await Scenario .check( datum.goal == BigInt(goal), "goal must not change" ) .await Scenario .check( datum.withdrawn <= datum.totalSum, "withdrawn must not exceed totalSum" ) .await case None => () // campaign fully consumed is ok } } .await } val results = Await.result(Scenario.runAll(emulator)(scenario), Duration(120, "s")) val violations = results.flatMap(_._2) assert( violations.isEmpty, s"Found violations: ${violations.map(v => s"${v.message} at ${v.location}")}" ) } } object CrowdfundingScenarioTest { import Scenario.futureToScenarioConversion private val crowdfundingContract = CrowdfundingContract.compiled.withErrorTraces private val donationMintingContract = DonationMintingContract.compiled.withErrorTraces private val crowdfundingScript = crowdfundingContract.script private val crowdfundingPolicyId = crowdfundingScript.scriptHash private val network = Network.Mainnet private val recipient = Alice private val donor1 = Bob private val donor2 = Charles private val recipientAddr = recipient.address(network) private val donor1Addr = donor1.address(network) private val donor2Addr = donor2.address(network) private val goal = 10_000_000L private val deadlineSlot: Long = 100L private val beforeDeadlineSlot: Long = deadlineSlot - 10 private val scriptAddress = crowdfundingContract.address(network) private def createEmulator(): Emulator = { val emulator = Emulator( initialUtxos = Map( Input(genesisHash, 0) -> TransactionOutput.Babbage( address = recipientAddr, value = Value.lovelace(50_000_000L) ), Input(genesisHash, 1) -> TransactionOutput.Babbage( address = recipientAddr, value = Value.lovelace(50_000_000L) ), Input(genesisHash, 2) -> TransactionOutput.Babbage( address = donor1Addr, value = Value.lovelace(50_000_000L) ), Input(genesisHash, 3) -> TransactionOutput.Babbage( address = donor1Addr, value = Value.lovelace(50_000_000L) ), Input(genesisHash, 4) -> TransactionOutput.Babbage( address = donor2Addr, value = Value.lovelace(50_000_000L) ), Input(genesisHash, 5) -> TransactionOutput.Babbage( address = donor2Addr, value = Value.lovelace(50_000_000L) ) ), initialContext = Context.testMainnet(), mutators = Set(PlutusScriptsTransactionMutator) ) emulator.setSlot(beforeDeadlineSlot) emulator } // ========================================================================= // Helpers // ========================================================================= private def extractPkh(address: ShelleyAddress): PubKeyHash = address.payment match case ShelleyPaymentPart.Key(hash) => PubKeyHash(hash) case _ => throw IllegalArgumentException("Expected key payment credential") private def addressFromPkh(pkh: PubKeyHash): ShelleyAddress = ShelleyAddress( network, ShelleyPaymentPart.Key(AddrKeyHash.fromByteString(pkh.hash)), ShelleyDelegationPart.Null ) private def getDonationScript(campaignId: ByteString): Script.PlutusV3 = { val appliedProgram = donationMintingContract.program $ campaignId.toData Script.PlutusV3(appliedProgram.cborByteString) } private def computeDonationPolicyId(campaignId: ByteString): ByteString = getDonationScript(campaignId).scriptHash private def hasCampaignNft(output: TransactionOutput, nftAsset: AssetName): Boolean = output.value.assets.assets .get(crowdfundingPolicyId) .exists(_.get(nftAsset).exists(_ > 0)) private def findCampaignOutputIdx(tx: Transaction, nftAsset: AssetName): Int = tx.body.value.outputs.indexWhere(sized => hasCampaignNft(sized.value, nftAsset)) private def findCampaignUtxo( reader: BlockchainReader, campaignId: ByteString ): Future[Option[Utxo]] = { given scala.concurrent.ExecutionContext = reader.executionContext val nftAsset = AssetName(campaignId) reader.findUtxos(scriptAddress).map(_.getOrElse(Map.empty)).map { utxos => utxos .find((_, output) => hasCampaignNft(output, nftAsset)) .map((input, output) => Utxo(input, output)) } } private def findDonationUtxos( reader: BlockchainReader, campaignId: ByteString, donationPolicyId: ScriptHash ): Future[Seq[Utxo]] = { given scala.concurrent.ExecutionContext = reader.executionContext val nftAsset = AssetName(campaignId) reader.findUtxos(scriptAddress).map(_.getOrElse(Map.empty)).map { utxos => utxos .filterNot((_, output) => hasCampaignNft(output, nftAsset)) .filter { case (_, output) => output.value.assets.assets .get(donationPolicyId) .exists(_.nonEmpty) } .map((input, output) => Utxo(input, output)) .toSeq } } private def tryGetCampaignDatum( reader: BlockchainReader, campaignId: ByteString ): Future[Option[CampaignDatum]] = { given scala.concurrent.ExecutionContext = reader.executionContext findCampaignUtxo(reader, campaignId).map( _.flatMap(u => u.output.inlineDatum.map(_.to[CampaignDatum])) ) } private def submitOrFail(tx: Transaction, actionName: String): Scenario[Unit] = async[Scenario] { Scenario.submit(tx).await match case Right(_) => () case Left(err) => throw RuntimeException(s"Failed to $actionName: $err") } private def spendCampaignAndDonations( cardanoInfo: CardanoInfo, campaignUtxo: Utxo, donationUtxos: Seq[Utxo], redeemer: Transaction => Data, requiredSigners: Set[AddrKeyHash] ): TxBuilder = donationUtxos.foldLeft( TxBuilder(cardanoInfo) .spend(campaignUtxo, redeemer, crowdfundingScript) .requireSignatures(requiredSigners) ) { (b, utxo) => b.spend(utxo, redeemer, crowdfundingScript) } private def withUpdatedCampaign( builder: TxBuilder, currentDatum: CampaignDatum, nftAsset: AssetName, newWithdrawn: BigInt, isFull: Boolean ): TxBuilder = if isFull then builder else val nftVal = Value.asset(crowdfundingPolicyId, nftAsset, 1L) val newDatum = currentDatum.copy(withdrawn = newWithdrawn) builder.payTo(scriptAddress, Value.lovelace(2_000_000L) + nftVal, newDatum) // ========================================================================= // Base actions // ========================================================================= private def createCampaignS(): Scenario[ByteString] = async[Scenario] { val reader = Scenario.snapshotReader.await val deadline = reader.cardanoInfo.slotConfig.slotToTime(deadlineSlot) val recipientPkh = extractPkh(recipientAddr) val recipientKeyHash = AddrKeyHash.fromByteString(recipientPkh.hash) given scala.concurrent.ExecutionContext = reader.executionContext val utxos = reader.findUtxos(recipientAddr).await.getOrElse(Map.empty) if utxos.isEmpty then throw RuntimeException("No UTxOs at recipient address") val firstUtxo = utxos.head val seedUtxo = Utxo(firstUtxo._1, firstUtxo._2) val txOutRef = scalus.cardano.onchain.plutus.v3.TxOutRef( scalus.cardano.onchain.plutus.v3.TxId(firstUtxo._1.transactionId), firstUtxo._1.index ) val campaignId = scalus.uplc.builtin.Builtins.blake2b_256( scalus.uplc.builtin.Builtins.serialiseData(txOutRef.toData) ) val donationPolicyId = computeDonationPolicyId(campaignId) val datum = CampaignDatum( totalSum = BigInt(0), goal = BigInt(goal), recipient = recipientPkh, deadline = BigInt(deadline), withdrawn = BigInt(0), donationPolicyId = donationPolicyId ) val redeemer = Action.Create( goal = BigInt(goal), recipient = recipientPkh, deadline = BigInt(deadline) ) val nftAsset = AssetName(campaignId) val mintedValue = Value.asset(crowdfundingPolicyId, nftAsset, 1L) val tx = TxBuilder(reader.cardanoInfo) .spend(seedUtxo) .mint(crowdfundingScript, Map(nftAsset -> 1L), redeemer) .requireSignature(recipientKeyHash) .payTo(scriptAddress, Value(Coin(2_000_000L)) + mintedValue, datum) .validTo(Instant.ofEpochMilli(deadline - 1000)) .complete(reader, recipientAddr) .await .sign(recipient.signer) .transaction submitOrFail(tx, "create campaign").await campaignId } private def donateS( campaignId: ByteString, donorAddress: ShelleyAddress, amount: Long, donor: Party ): Scenario[Unit] = async[Scenario] { val reader = Scenario.snapshotReader.await given scala.concurrent.ExecutionContext = reader.executionContext val campaignUtxo = findCampaignUtxo(reader, campaignId).await .getOrElse(throw RuntimeException("Campaign not found")) val currentDatum = campaignUtxo.output.requireInlineDatum.to[CampaignDatum] val donationPolicyId = ScriptHash.fromByteString(currentDatum.donationPolicyId) val donationScript = getDonationScript(campaignId) // can't use currentDatum.copy() inside async block (dotty-cps-async macro bug) val newDatum = CampaignDatum( totalSum = currentDatum.totalSum + BigInt(amount), goal = currentDatum.goal, recipient = currentDatum.recipient, deadline = currentDatum.deadline, withdrawn = currentDatum.withdrawn, donationPolicyId = currentDatum.donationPolicyId ) val donationAsset = AssetName(DonationMintingPolicy.donationTokenName) val donationTokenValue = Value.asset(donationPolicyId, donationAsset, 1L) val nftAsset = AssetName(campaignId) val nftValue = Value.asset(crowdfundingPolicyId, nftAsset, 1L) val newCampaignValue = Value.lovelace(campaignUtxo.output.value.coin.value + amount) + nftValue val donationUtxoValue = Value.lovelace(amount) + donationTokenValue val donorPkh = extractPkh(donorAddress) val donationDatum = DonationDatum(donorPkh, BigInt(amount)) val donateRedeemer: Transaction => Data = { (tx: Transaction) => val inputIdx = tx.body.value.inputs.toSeq.indexOf(campaignUtxo.input) val campaignOutputIdx = findCampaignOutputIdx(tx, nftAsset) val donationOutputIdx = tx.body.value.outputs.indexWhere { sized => sized.value.address == scriptAddress && !hasCampaignNft(sized.value, nftAsset) } Action .Donate( BigInt(amount), BigInt(inputIdx), BigInt(campaignOutputIdx), BigInt(donationOutputIdx) ) .toData } val tx = TxBuilder(reader.cardanoInfo) .spend(campaignUtxo, donateRedeemer, crowdfundingScript) .mint(donationScript, Map(donationAsset -> 1L), donateRedeemer) .payTo(scriptAddress, newCampaignValue, newDatum) .payTo(scriptAddress, donationUtxoValue, donationDatum) .validTo(Instant.ofEpochMilli(currentDatum.deadline.toLong - 1000)) .complete(reader, donorAddress) .await .sign(donor.signer) .transaction submitOrFail(tx, "donate").await } private def withdrawS(campaignId: ByteString): Scenario[Unit] = async[Scenario] { val reader = Scenario.snapshotReader.await given scala.concurrent.ExecutionContext = reader.executionContext val campaignUtxo = findCampaignUtxo(reader, campaignId).await .getOrElse(throw RuntimeException("Campaign not found")) val currentDatum = campaignUtxo.output.requireInlineDatum.to[CampaignDatum] val recipientPkh = currentDatum.recipient val recipientKeyHash = AddrKeyHash.fromByteString(recipientPkh.hash) val recipientAddress = addressFromPkh(recipientPkh) val donationPolicyId = ScriptHash.fromByteString(currentDatum.donationPolicyId) val donationScript = getDonationScript(campaignId) val donationUtxos = findDonationUtxos(reader, campaignId, donationPolicyId).await if donationUtxos.isEmpty then throw RuntimeException("No donations to withdraw") val totalWithdrawAmount: BigInt = donationUtxos .map(_.output.requireInlineDatum.to[DonationDatum].amount) .sum val newWithdrawn = currentDatum.withdrawn + totalWithdrawAmount val isFullWithdrawal = newWithdrawn == currentDatum.totalSum val donationAsset = AssetName(DonationMintingPolicy.donationTokenName) val burnMap = Map(donationAsset -> -donationUtxos.size.toLong) val nftAsset = AssetName(campaignId) val withdrawRedeemer: Transaction => Data = { (tx: Transaction) => val inputIdx = tx.body.value.inputs.toSeq.indexOf(campaignUtxo.input) val campaignOutputIdx = if isFullWithdrawal then -1 else findCampaignOutputIdx(tx, nftAsset) val recipientOutputIdx = tx.body.value.outputs.indexWhere { sized => sized.value.address == recipientAddress } val donationInputIndices = donationUtxos .map(u => BigInt(tx.body.value.inputs.toSeq.indexOf(u.input))) .sorted Action .Withdraw( BigInt(inputIdx), BigInt(campaignOutputIdx), BigInt(recipientOutputIdx), scalus.cardano.onchain.plutus.prelude.List.from(donationInputIndices) ) .toData } val withRecipient = spendCampaignAndDonations( reader.cardanoInfo, campaignUtxo, donationUtxos, withdrawRedeemer, Set(recipientKeyHash) ).mint(donationScript, burnMap, withdrawRedeemer) .payTo(recipientAddress, Value.lovelace(totalWithdrawAmount.toLong)) val tx = withUpdatedCampaign( withRecipient, currentDatum, nftAsset, newWithdrawn, isFullWithdrawal ) .validFrom(Instant.ofEpochMilli(currentDatum.deadline.toLong + 1000)) .complete(reader, recipientAddress) .await .sign(recipient.signer) .transaction submitOrFail(tx, "withdraw").await } private def reclaimS(campaignId: ByteString): Scenario[Unit] = async[Scenario] { val reader = Scenario.snapshotReader.await given scala.concurrent.ExecutionContext = reader.executionContext val campaignUtxo = findCampaignUtxo(reader, campaignId).await .getOrElse(throw RuntimeException("Campaign not found")) val currentDatum = campaignUtxo.output.requireInlineDatum.to[CampaignDatum] val donationPolicyId = ScriptHash.fromByteString(currentDatum.donationPolicyId) val donationScript = getDonationScript(campaignId) val donationUtxos = findDonationUtxos(reader, campaignId, donationPolicyId).await if donationUtxos.isEmpty then throw RuntimeException("No donations to reclaim") val donorInfos = donationUtxos.map { utxo => val dd = utxo.output.requireInlineDatum.to[DonationDatum] val addr = addressFromPkh(dd.donor) (addr, dd.donor, dd.amount, utxo.output.value.coin.value) } val totalReclaimAmount: BigInt = donorInfos.map(_._3).sum val newWithdrawn = currentDatum.withdrawn + totalReclaimAmount val isFullReclaim = newWithdrawn == currentDatum.totalSum val donorKeyHashes = donorInfos.map { case (_, pkh, _, _) => AddrKeyHash.fromByteString(pkh.hash) }.toSet val donationAsset = AssetName(DonationMintingPolicy.donationTokenName) val burnMap = Map(donationAsset -> -donationUtxos.size.toLong) val nftAsset = AssetName(campaignId) val reclaimRedeemer: Transaction => Data = { (tx: Transaction) => val inputIdx = tx.body.value.inputs.toSeq.indexOf(campaignUtxo.input) val campaignOutputIdx = if isFullReclaim then -1 else findCampaignOutputIdx(tx, nftAsset) val sortedPairs = donationUtxos .zip(donorInfos) .map { case (utxo, (donorAddr, _, _, _)) => val donationIdx = BigInt(tx.body.value.inputs.toSeq.indexOf(utxo.input)) val reclaimerOutIdx = BigInt(tx.body.value.outputs.indexWhere(_.value.address == donorAddr)) (donationIdx, reclaimerOutIdx) } .sortBy(_._1) Action .Reclaim( BigInt(inputIdx), BigInt(campaignOutputIdx), scalus.cardano.onchain.plutus.prelude.List.from(sortedPairs.map(_._1)), scalus.cardano.onchain.plutus.prelude.List.from(sortedPairs.map(_._2)) ) .toData } val withPayments = donorInfos.foldLeft( spendCampaignAndDonations( reader.cardanoInfo, campaignUtxo, donationUtxos, reclaimRedeemer, donorKeyHashes ).mint(donationScript, burnMap, reclaimRedeemer) ) { case (b, (donorAddr, _, _, utxoLovelace)) => b.payTo(donorAddr, Value.lovelace(utxoLovelace)) } val feePayerAddr = donorInfos.head._1 val allSigners = new TransactionSigner( Set(donor1.account.paymentKeyPair, donor2.account.paymentKeyPair) ) val tx = withUpdatedCampaign( withPayments, currentDatum, nftAsset, newWithdrawn, isFullReclaim ) .validFrom(Instant.ofEpochMilli(currentDatum.deadline.toLong + 1000)) .complete(reader, feePayerAddr) .await .sign(allSigners) .transaction submitOrFail(tx, "reclaim").await } // ========================================================================= // Try actions with precondition/postcondition checks // ========================================================================= /** Wraps an action with precondition checking via flatMapTry. */ private def tryActionS( actionName: String, shouldSucceed: Scenario[Boolean], action: Scenario[Unit] ): Scenario[Unit] = async[Scenario] { val expected = shouldSucceed.await Scenario.scenarioLogicMonad .flatMapTry(action) { case Success(_) => Scenario.check(expected, s"$actionName succeeded but preconditions not met") case Failure(_) => Scenario.check(!expected, s"$actionName failed but preconditions were met") } .await } private def hasDonationsS( reader: BlockchainReader, campaignId: ByteString, datum: CampaignDatum ): Future[Boolean] = { given scala.concurrent.ExecutionContext = reader.executionContext val dpId = ScriptHash.fromByteString(datum.donationPolicyId) findDonationUtxos(reader, campaignId, dpId).map(_.nonEmpty) } private def tryDonateS( campaignId: ByteString, donorAddress: ShelleyAddress, amount: Long, donor: Party ): Scenario[Unit] = tryActionS( "donate", shouldSucceed = async[Scenario] { val reader = Scenario.snapshotReader.await val maybeDatum = tryGetCampaignDatum(reader, campaignId).await val slotTime = reader.cardanoInfo.slotConfig.slotToTime(reader.currentSlot.await) maybeDatum.exists(d => slotTime < d.deadline.toLong) }, action = donateS(campaignId, donorAddress, amount, donor) ) private def tryWithdrawS(campaignId: ByteString): Scenario[Unit] = tryActionS( "withdraw", shouldSucceed = async[Scenario] { val reader = Scenario.snapshotReader.await val maybeDatum = tryGetCampaignDatum(reader, campaignId).await val slotTime = reader.cardanoInfo.slotConfig.slotToTime(reader.currentSlot.await) maybeDatum match case Some(d) => hasDonationsS(reader, campaignId, d).await && slotTime > d.deadline.toLong && d.totalSum >= d.goal case None => false }, action = withdrawS(campaignId) ) private def tryReclaimS(campaignId: ByteString): Scenario[Unit] = tryActionS( "reclaim", shouldSucceed = async[Scenario] { val reader = Scenario.snapshotReader.await val maybeDatum = tryGetCampaignDatum(reader, campaignId).await val slotTime = reader.cardanoInfo.slotConfig.slotToTime(reader.currentSlot.await) maybeDatum match case Some(d) => hasDonationsS(reader, campaignId, d).await && slotTime > d.deadline.toLong && d.totalSum < d.goal case None => false }, action = reclaimS(campaignId) ) } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/crowdfunding/CrowdfundingValidatorTest.scala ```scala package scalus.examples.crowdfunding import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.ByteString import scalus.uplc.builtin.Data.toData import scalus.cardano.onchain.plutus.v1.{Address, Credential, PubKeyHash, Value} import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.v3.ScriptInfo.SpendingScript import scalus.cardano.onchain.plutus.prelude.{List, Option} import scalus.testing.kit.ScalusTest class CrowdfundingValidatorTest extends AnyFunSuite, ScalusTest { private val crowdfundingContract = CrowdfundingContract.compiled.withErrorTraces test( s"Crowdfunding validator size is ${CrowdfundingContract.compiled.script.script.size} bytes" ) { println( s"Crowdfunding validator size: ${CrowdfundingContract.compiled.script.script.size} bytes" ) assert(CrowdfundingContract.compiled.script.script.size > 0) } test( s"Donation minting policy size is ${DonationMintingContract.compiled.script.script.size} bytes" ) { println( s"Donation minting policy size: ${DonationMintingContract.compiled.script.script.size} bytes" ) assert(DonationMintingContract.compiled.script.script.size > 0) } test("donationTokenName is fixed empty ByteString") { assert( DonationMintingPolicy.donationTokenName == ByteString.empty, "Donation token name should be empty ByteString" ) } test("Create campaign - validates recipient signature required") { val recipientPkh = random[PubKeyHash] val otherPkh = random[PubKeyHash] val deadline = BigInt(System.currentTimeMillis() + 86400000) // 1 day from now val goal = BigInt(10_000_000) val txOutRef = random[TxOutRef] val campaignId = scalus.uplc.builtin.Builtins.blake2b_256( scalus.uplc.builtin.Builtins.serialiseData(txOutRef.toData) ) val policyId = crowdfundingContract.script.scriptHash // Donation policy ID would be computed from applied program val donationPolicyId = ByteString.fromHex("00" * 28) val datum = CampaignDatum( totalSum = BigInt(0), goal = goal, recipient = recipientPkh, deadline = deadline, withdrawn = BigInt(0), donationPolicyId = donationPolicyId ) val redeemer = Action.Create(goal, recipientPkh, deadline) // Create a transaction context where recipient did NOT sign val context = ScriptContext( txInfo = TxInfo( inputs = List( TxInInfo( outRef = txOutRef, resolved = TxOut( address = Address(Credential.PubKeyCredential(otherPkh), Option.None), value = Value.lovelace(10_000_000) ) ) ), outputs = List( TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), value = Value.lovelace(5_000_000) + Value(policyId, campaignId, BigInt(1)), datum = OutputDatum.OutputDatum(datum.toData) ) ), mint = Value(policyId, campaignId, BigInt(1)), signatories = List(otherPkh), // NOT the recipient! validRange = Interval.before(deadline), id = random[TxId] ), redeemer = redeemer.toData, scriptInfo = ScriptInfo.MintingScript(policyId) ) val program = crowdfundingContract.program $ context.toData val result = program.evaluateDebug assert(result.isFailure, "Should fail when recipient doesn't sign") assert( result.logs.exists(_.contains("Recipient must sign")), s"Expected 'Recipient must sign' error, got: ${result.logs.mkString(", ")}" ) } test("Create campaign - validates goal must be positive") { val recipientPkh = random[PubKeyHash] val deadline = BigInt(System.currentTimeMillis() + 86400000) val goal = BigInt(0) // Invalid: zero goal val txOutRef = random[TxOutRef] val policyId = crowdfundingContract.script.scriptHash val redeemer = Action.Create(goal, recipientPkh, deadline) val context = ScriptContext( txInfo = TxInfo( inputs = List( TxInInfo( outRef = txOutRef, resolved = TxOut( address = Address(Credential.PubKeyCredential(recipientPkh), Option.None), value = Value.lovelace(10_000_000) ) ) ), outputs = List.Nil, mint = Value.zero, signatories = List(recipientPkh), validRange = Interval.before(deadline), id = random[TxId] ), redeemer = redeemer.toData, scriptInfo = ScriptInfo.MintingScript(policyId) ) val program = crowdfundingContract.program $ context.toData val result = program.evaluateDebug assert(result.isFailure, "Should fail when goal is zero") assert( result.logs.exists(_.contains("Goal must be positive")), s"Expected 'Goal must be positive' error, got: ${result.logs.mkString(", ")}" ) } test("Donate - validates before deadline") { val recipientPkh = random[PubKeyHash] val deadline = BigInt(1000) // In the past val donationPolicyId = ByteString.fromHex("11" * 28) val currentDatum = CampaignDatum( totalSum = BigInt(0), goal = BigInt(10_000_000), recipient = recipientPkh, deadline = deadline, withdrawn = BigInt(0), donationPolicyId = donationPolicyId ) val txOutRef = random[TxOutRef] val policyId = crowdfundingContract.script.scriptHash val amount = BigInt(5_000_000) val redeemer = Action.Donate( amount = amount, campaignInputIdx = BigInt(0), campaignOutputIdx = BigInt(0), donationOutputIdx = BigInt(1) ) val context = ScriptContext( txInfo = TxInfo( inputs = List( TxInInfo( outRef = txOutRef, resolved = TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), value = Value.lovelace(5_000_000), datum = OutputDatum.OutputDatum(currentDatum.toData) ) ) ), outputs = List.Nil, mint = Value.zero, signatories = List.Nil, validRange = Interval.after(deadline + 1000), // After deadline! id = random[TxId] ), redeemer = redeemer.toData, scriptInfo = SpendingScript(txOutRef, Option.None) ) val program = crowdfundingContract.program $ context.toData val result = program.evaluateDebug assert(result.isFailure, "Should fail when donating after deadline") assert( result.logs.exists(_.contains("before deadline")), s"Expected deadline error, got: ${result.logs.mkString(", ")}" ) } test("Withdraw - validates goal must be reached") { val recipientPkh = random[PubKeyHash] val deadline = BigInt(1000) val donationPolicyId = ByteString.fromHex("11" * 28) val campaignId = ByteString.fromHex("cc" * 32) // Mock campaign NFT token name val currentDatum = CampaignDatum( totalSum = BigInt(5_000_000), // Less than goal goal = BigInt(10_000_000), recipient = recipientPkh, deadline = deadline, withdrawn = BigInt(0), donationPolicyId = donationPolicyId ) val txOutRef = random[TxOutRef] val policyId = crowdfundingContract.script.scriptHash val redeemer = Action.Withdraw( campaignInputIdx = BigInt(0), campaignOutputIdx = BigInt(-1), recipientOutputIdx = BigInt(0), donationInputIndices = List.Nil ) val context = ScriptContext( txInfo = TxInfo( inputs = List( TxInInfo( outRef = txOutRef, resolved = TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), // Include campaign NFT in the value value = Value.lovelace(5_000_000) + Value(policyId, campaignId, BigInt(1)), datum = OutputDatum.OutputDatum(currentDatum.toData) ) ) ), outputs = List.Nil, mint = Value.zero, signatories = List(recipientPkh), validRange = Interval.after(deadline + 1), // Must start AFTER deadline id = random[TxId] ), redeemer = redeemer.toData, scriptInfo = SpendingScript(txOutRef, Option.None) ) val program = crowdfundingContract.program $ context.toData val result = program.evaluateDebug assert(result.isFailure, "Should fail when goal not reached") assert( result.logs.exists(_.contains("Goal must be reached")), s"Expected goal error, got: ${result.logs.mkString(", ")}" ) } test("Reclaim - validates goal must NOT be reached") { val recipientPkh = random[PubKeyHash] val deadline = BigInt(1000) val donationPolicyId = ByteString.fromHex("11" * 28) val campaignId = ByteString.fromHex("cc" * 32) // Mock campaign NFT token name val currentDatum = CampaignDatum( totalSum = BigInt(15_000_000), // More than goal - success! goal = BigInt(10_000_000), recipient = recipientPkh, deadline = deadline, withdrawn = BigInt(0), donationPolicyId = donationPolicyId ) val txOutRef = random[TxOutRef] val policyId = crowdfundingContract.script.scriptHash val redeemer = Action.Reclaim( campaignInputIdx = BigInt(0), campaignOutputIdx = BigInt(-1), donationInputIndices = List.Nil, reclaimerOutputIndices = List.Nil ) val context = ScriptContext( txInfo = TxInfo( inputs = List( TxInInfo( outRef = txOutRef, resolved = TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), // Include campaign NFT in the value value = Value.lovelace(15_000_000) + Value(policyId, campaignId, BigInt(1)), datum = OutputDatum.OutputDatum(currentDatum.toData) ) ) ), outputs = List.Nil, mint = Value.zero, signatories = List.Nil, validRange = Interval.after(deadline + 1), // Must start AFTER deadline id = random[TxId] ), redeemer = redeemer.toData, scriptInfo = SpendingScript(txOutRef, Option.None) ) val program = crowdfundingContract.program $ context.toData val result = program.evaluateDebug assert(result.isFailure, "Should fail when goal was reached") assert( result.logs.exists(_.contains("Cannot reclaim if goal was reached")), s"Expected reclaim error, got: ${result.logs.mkString(", ")}" ) } test("Reclaim - rejects duplicate donation input indices (double-spend prevention)") { val recipientPkh = random[PubKeyHash] val donorPkh = random[PubKeyHash] val deadline = BigInt(1000) val donationPolicyId = ByteString.fromHex("11" * 28) val campaignId = ByteString.fromHex("cc" * 32) // Mock campaign NFT token name val donationAmount = BigInt(5_000_000) val tokenName = DonationMintingPolicy.donationTokenName // Fixed token name val currentDatum = CampaignDatum( totalSum = donationAmount, // Less than goal - reclaim allowed goal = BigInt(10_000_000), recipient = recipientPkh, deadline = deadline, withdrawn = BigInt(0), donationPolicyId = donationPolicyId ) val donationDatum = DonationDatum(donorPkh, donationAmount) val campaignTxOutRef = random[TxOutRef] val donationTxOutRef = random[TxOutRef] val policyId = crowdfundingContract.script.scriptHash // Attacker tries to use the same donation input index twice val redeemer = Action.Reclaim( campaignInputIdx = BigInt(0), campaignOutputIdx = BigInt(-1), donationInputIndices = List(BigInt(1), BigInt(1)), // DUPLICATE INDEX - attack attempt reclaimerOutputIndices = List(BigInt(0), BigInt(1)) // Two outputs to drain funds ) val context = ScriptContext( txInfo = TxInfo( inputs = List( TxInInfo( outRef = campaignTxOutRef, resolved = TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), // Include campaign NFT in the value value = Value.lovelace(2_000_000) + Value(policyId, campaignId, BigInt(1)), datum = OutputDatum.OutputDatum(currentDatum.toData) ) ), TxInInfo( outRef = donationTxOutRef, resolved = TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), value = Value .lovelace(donationAmount) + Value(donationPolicyId, tokenName, BigInt(1)), datum = OutputDatum.OutputDatum(donationDatum.toData) ) ) ), outputs = List( // Two outputs trying to claim the same donation TxOut( address = Address(Credential.PubKeyCredential(donorPkh), Option.None), value = Value.lovelace(donationAmount), datum = OutputDatum.NoOutputDatum ), TxOut( address = Address(Credential.PubKeyCredential(donorPkh), Option.None), value = Value.lovelace(donationAmount), datum = OutputDatum.NoOutputDatum ) ), mint = Value(donationPolicyId, tokenName, BigInt(-1)), signatories = List(donorPkh), validRange = Interval.after(deadline + 1), id = random[TxId] ), redeemer = redeemer.toData, scriptInfo = SpendingScript(campaignTxOutRef, Option.None) ) val program = crowdfundingContract.program $ context.toData val result = program.evaluateDebug assert(result.isFailure, "Should fail when duplicate donation indices are used") assert( result.logs.exists(_.contains("strictly ascending")), s"Expected duplicate index error, got: ${result.logs.mkString(", ")}" ) } test("Reclaim - rejects fewer reclaimer outputs than donations (truncation sweep)") { // Attack: consume two donation UTxOs but supply only one reclaimer output index. // `donationInputIndices.zip(reclaimerOutputIndices)` truncates to the shorter list, so // the validator only checks a refund for the first donation. The second donation's ADA // is consumed (its token burned) but never returned to its donor — the attacker keeps it // as change. A length-equality guard must reject this. val deadline = BigInt(1000) val donationPolicyId = ByteString.fromHex("11" * 28) val campaignId = ByteString.fromHex("cc" * 32) val tokenName = DonationMintingPolicy.donationTokenName val donor1 = random[PubKeyHash] val donor2 = random[PubKeyHash] val donationAmount = BigInt(3_000_000) val currentDatum = CampaignDatum( totalSum = BigInt(6_000_000), // two 3M donations, goal not reached goal = BigInt(10_000_000), recipient = random[PubKeyHash], deadline = deadline, withdrawn = BigInt(0), donationPolicyId = donationPolicyId ) val policyId = crowdfundingContract.script.scriptHash val campaignTxOutRef = random[TxOutRef] // Only donor1 gets a refund output; donor2's funds are swept. val redeemer = Action.Reclaim( campaignInputIdx = BigInt(0), campaignOutputIdx = BigInt(0), donationInputIndices = List(BigInt(1), BigInt(2)), reclaimerOutputIndices = List(BigInt(1)) // shorter than donationInputIndices ) def donationInput(donor: PubKeyHash) = TxInInfo( outRef = random[TxOutRef], resolved = TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), value = Value.lovelace(donationAmount) + Value(donationPolicyId, tokenName, BigInt(1)), datum = OutputDatum.OutputDatum(DonationDatum(donor, donationAmount).toData) ) ) val updatedDatum = currentDatum.copy(withdrawn = donationAmount) val context = ScriptContext( txInfo = TxInfo( inputs = List( TxInInfo( outRef = campaignTxOutRef, resolved = TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), value = Value.lovelace(2_000_000) + Value(policyId, campaignId, BigInt(1)), datum = OutputDatum.OutputDatum(currentDatum.toData) ) ), donationInput(donor1), donationInput(donor2) ), outputs = List( // 0: continuing campaign (partial reclaim) TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), value = Value.lovelace(2_000_000) + Value(policyId, campaignId, BigInt(1)), datum = OutputDatum.OutputDatum(updatedDatum.toData) ), // 1: donor1 refund (the only one paid) TxOut( address = Address(Credential.PubKeyCredential(donor1), Option.None), value = Value.lovelace(donationAmount), datum = OutputDatum.NoOutputDatum ), // 2: donor2's funds swept by attacker (not referenced) TxOut( address = Address(Credential.PubKeyCredential(random[PubKeyHash]), Option.None), value = Value.lovelace(donationAmount), datum = OutputDatum.NoOutputDatum ) ), mint = Value(donationPolicyId, tokenName, BigInt(-2)), signatories = List(donor1), validRange = Interval.after(deadline + 1), id = random[TxId] ), redeemer = redeemer.toData, scriptInfo = SpendingScript(campaignTxOutRef, Option.None) ) val program = crowdfundingContract.program $ context.toData val result = program.evaluateDebug assert(result.isFailure, "Should fail when reclaimer outputs are fewer than donations") assert( result.logs.exists(_.contains("must match")), s"Expected count-mismatch error, got: ${result.logs.mkString(", ")}" ) } test("Reclaim - rejects duplicate reclaimer output indices (shared-output theft)") { // Attack: two donations from the same donor, both pointed at a single refund output. // Lengths match, so a length check alone passes, but only one output exists — the second // donation's ADA is swept. The reclaimer output indices must be pairwise distinct. val deadline = BigInt(1000) val donationPolicyId = ByteString.fromHex("11" * 28) val campaignId = ByteString.fromHex("cc" * 32) val tokenName = DonationMintingPolicy.donationTokenName val donor = random[PubKeyHash] val donationAmount = BigInt(3_000_000) val currentDatum = CampaignDatum( totalSum = BigInt(6_000_000), goal = BigInt(10_000_000), recipient = random[PubKeyHash], deadline = deadline, withdrawn = BigInt(0), donationPolicyId = donationPolicyId ) val policyId = crowdfundingContract.script.scriptHash val campaignTxOutRef = random[TxOutRef] // Both donations aim at output index 0; full reclaim so no campaign output needed. val redeemer = Action.Reclaim( campaignInputIdx = BigInt(0), campaignOutputIdx = BigInt(-1), donationInputIndices = List(BigInt(1), BigInt(2)), reclaimerOutputIndices = List(BigInt(0), BigInt(0)) // duplicate index ) def donationInput() = TxInInfo( outRef = random[TxOutRef], resolved = TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), value = Value.lovelace(donationAmount) + Value(donationPolicyId, tokenName, BigInt(1)), datum = OutputDatum.OutputDatum(DonationDatum(donor, donationAmount).toData) ) ) val context = ScriptContext( txInfo = TxInfo( inputs = List( TxInInfo( outRef = campaignTxOutRef, resolved = TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), value = Value.lovelace(2_000_000) + Value(policyId, campaignId, BigInt(1)), datum = OutputDatum.OutputDatum(currentDatum.toData) ) ), donationInput(), donationInput() ), outputs = List( // 0: a single refund of one donation; the other 3M is swept as change TxOut( address = Address(Credential.PubKeyCredential(donor), Option.None), value = Value.lovelace(donationAmount), datum = OutputDatum.NoOutputDatum ) ), mint = Value(donationPolicyId, tokenName, BigInt(-2)), signatories = List(donor), validRange = Interval.after(deadline + 1), id = random[TxId] ), redeemer = redeemer.toData, scriptInfo = SpendingScript(campaignTxOutRef, Option.None) ) val program = crowdfundingContract.program $ context.toData val result = program.evaluateDebug assert(result.isFailure, "Should fail when two donations share one reclaimer output") assert( result.logs.exists(_.contains("distinct")), s"Expected distinctness error, got: ${result.logs.mkString(", ")}" ) } } ``` # Example: decentralizedidentity ## scalus-examples/jvm/src/main/scala/scalus/examples/decentralizedidentity/DecentralizedIdentityContract.scala ```scala package scalus.examples.decentralizedidentity import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 import scalus.uplc.builtin.Data import scalus.cardano.onchain.plutus.v3.TxOutRef /** Blueprint and compiled script for the decentralized identity contract. */ object DecentralizedIdentityContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(DecentralizedIdentityValidator.validate) lazy val blueprint = Blueprint.plutusV3[TxOutRef, IdentityDatum, SpendAction]( title = "Decentralized identity", description = "Self-sovereign identity: a one-shot UTxO mints a unique identity NFT, after which the " + "owner can issue delegations and attribute credentials. Datum/redeemer shown for the " + "primary identity-spend path (delegation and attribute UTxOs carry their own datums).", version = "1.0.0", license = Some("Apache-2.0"), // DataParameterizedValidator applies the one-shot TxOutRef parameter as Data on the UPLC // level; the cast only re-labels the phantom type for schema derivation. compiled = compiled.asInstanceOf[PlutusV3[TxOutRef => Data => Unit]] ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/decentralizedidentity/DecentralizedIdentityTransactions.scala ```scala package scalus.examples.decentralizedidentity import scalus.uplc.builtin.{ByteString, Data} import scalus.uplc.builtin.Builtins.blake2b_224 import scalus.uplc.builtin.Data.toData import scalus.cardano.address.Address import scalus.cardano.ledger.* import scalus.cardano.txbuilder.* import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.cardano.onchain.plutus.v3.{TxId, TxOutRef} import scalus.uplc.PlutusV3 import scalus.cardano.node.{BlockchainProvider, NetworkSubmitError, NodeSubmitError, SubmitError} import java.time.Instant import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success, Try} /** Transaction builder for Decentralized Identity operations. * * Creates and manages identity NFTs, delegation tokens, and attribute tokens. */ case class DecentralizedIdentityTransactions( env: CardanoInfo, evaluator: PlutusScriptEvaluator, contract: PlutusV3[Data => Data => Unit], seed: Utxo ) { private val parameterizedScript = { val txOutRef = TxOutRef(TxId(seed.input.transactionId), BigInt(seed.input.index)) contract.apply(txOutRef.toData) } val scriptAddr: Address = parameterizedScript.address(env.network) val policyId: PolicyId = parameterizedScript.script.scriptHash /** The unique identity token name, derived from seed UTXO. */ val identityTokenName: ByteString = { val uniqueId = blake2b_224( seed.input.transactionId ++ ByteString.fromArray( BigInt(seed.input.index).toByteArray ) ) DecentralizedIdentityValidator.identityTokenName(uniqueId) } // ===== Helper methods ===== /** Find input index for a given UTXO in a transaction */ private def findInputIndex(tx: Transaction, utxo: Utxo): Int = { val idx = tx.body.value.inputs.toSeq.indexWhere { input => input.transactionId == utxo.input.transactionId && input.index == utxo.input.index } require(idx >= 0, s"Input not found in transaction: ${utxo.input}") idx } /** Find reference input index for a given UTXO in a transaction */ private def findRefInputIndex(tx: Transaction, utxo: Utxo): Int = { val idx = tx.body.value.referenceInputs.toSeq.indexWhere { input => input.transactionId == utxo.input.transactionId && input.index == utxo.input.index } require(idx >= 0, s"Reference input not found in transaction: ${utxo.input}") idx } /** Find output index by address and asset */ private def findOutputIndex(tx: Transaction, address: Address, asset: ByteString): Int = { val idx = tx.body.value.outputs.toSeq.indexWhere { output => output.value.address == address && output.value.value.assets.assets.exists { case (cs, tokens) => cs == policyId && tokens.get(AssetName(asset)).exists(_ > 0) } } require(idx >= 0, s"Output not found for address $address with asset") idx } // ===== Public API ===== /** Create a new identity. * * Mints an identity NFT at the script address with the owner's PubKeyHash in the datum. */ def createIdentity( utxos: Utxos, ownerPkh: AddrKeyHash, changeAddress: Address, signer: TransactionSigner ): Transaction = { val datum = IdentityDatum(PubKeyHash(ownerPkh)) val idAsset = AssetName(identityTokenName) def buildMintRedeemer(tx: Transaction): Data = { val seedIndex = findInputIndex(tx, seed) val identityOutIndex = findOutputIndex(tx, scriptAddr, identityTokenName) MintAction.CreateIdentity(BigInt(seedIndex), BigInt(identityOutIndex)).toData } TxBuilder(env, evaluator) .spend(seed) .mint( parameterizedScript, Map(idAsset -> 1L), buildMintRedeemer ) .payTo(scriptAddr, Value.asset(policyId, idAsset, 1), datum) .complete(availableUtxos = utxos, changeAddress) .sign(signer) .transaction } /** Transfer identity ownership. * * Spends the identity UTXO and re-creates it with a new owner. Both current and new owner must * sign. */ def transferOwnership( utxos: Utxos, identityUtxo: Utxo, newOwnerPkh: AddrKeyHash, changeAddress: Address, ownerSigner: TransactionSigner, newOwnerSigner: TransactionSigner ): Transaction = { val newDatum = IdentityDatum(PubKeyHash(newOwnerPkh)) def buildSpendRedeemer(tx: Transaction): Data = { val identityOutIndex = findOutputIndex(tx, scriptAddr, identityTokenName) SpendAction.TransferOwnership(PubKeyHash(newOwnerPkh), BigInt(identityOutIndex)).toData } val oldOwnerPkh = identityUtxo.output.inlineDatum.get.to[IdentityDatum].ownerPkh.hash TxBuilder(env, evaluator) .spend( identityUtxo, buildSpendRedeemer, parameterizedScript ) .requireSignatures(Set(AddrKeyHash(oldOwnerPkh), newOwnerPkh)) .payTo(scriptAddr, identityUtxo.output.value, newDatum) .complete(availableUtxos = utxos, changeAddress) .sign(ownerSigner) .sign(newOwnerSigner) .transaction } /** Add a delegate. * * Identity owner mints a delegation token at the script address. The identity UTXO is used as * a reference input. */ def addDelegate( utxos: Utxos, identityUtxo: Utxo, delegatePkh: AddrKeyHash, validFrom: Instant, validUntil: Instant, delegateType: ByteString, changeAddress: Address, signer: TransactionSigner ): Transaction = { val ownerPkh = identityUtxo.output.inlineDatum.get.to[IdentityDatum].ownerPkh.hash val delegTn = DecentralizedIdentityValidator.delegationTokenName( identityTokenName, PubKeyHash(delegatePkh) ) val delegAsset = AssetName(delegTn) val datum = DelegationDatum( identityTokenName = identityTokenName, delegatePkh = PubKeyHash(delegatePkh), validFrom = BigInt(validFrom.toEpochMilli), validUntil = BigInt(validUntil.toEpochMilli), delegateType = delegateType ) def buildMintRedeemer(tx: Transaction): Data = { val identityRefInputIndex = findRefInputIndex(tx, identityUtxo) val delegationOutIndex = findOutputIndex(tx, scriptAddr, delegTn) MintAction.AddDelegate(BigInt(identityRefInputIndex), BigInt(delegationOutIndex)).toData } TxBuilder(env, evaluator) .references(identityUtxo) .mint( parameterizedScript, Map(delegAsset -> 1L), buildMintRedeemer ) .requireSignature(AddrKeyHash(ownerPkh)) .payTo(scriptAddr, Value.asset(policyId, delegAsset, 1), datum) .complete(availableUtxos = utxos, changeAddress) .sign(signer) .transaction } /** Revoke a delegate. * * Identity owner burns a delegation token. The identity UTXO is used as a reference input to * verify ownership. */ def revokeDelegate( utxos: Utxos, identityUtxo: Utxo, delegationUtxo: Utxo, changeAddress: Address, signer: TransactionSigner ): Transaction = { val ownerPkh = identityUtxo.output.inlineDatum.get.to[IdentityDatum].ownerPkh.hash val delegDatum = delegationUtxo.output.inlineDatum.get.to[DelegationDatum] val delegTn = DecentralizedIdentityValidator.delegationTokenName( delegDatum.identityTokenName, delegDatum.delegatePkh ) val delegAsset = AssetName(delegTn) TxBuilder(env, evaluator) .references(identityUtxo) .spend( delegationUtxo, SpendAction.RevokeDelegate, parameterizedScript ) .requireSignature(AddrKeyHash(ownerPkh)) .mint(parameterizedScript, Map(delegAsset -> -1L), MintAction.Burn) .complete(availableUtxos = utxos, changeAddress) .sign(signer) .transaction } /** Publish an attribute. * * A delegate publishes an attribute on behalf of the identity. The delegation UTXO is used as * a reference input to verify the delegate's authority. */ def publishAttribute( utxos: Utxos, delegationUtxo: Utxo, key: ByteString, value: ByteString, validFrom: Instant, validUntil: Instant, changeAddress: Address, signer: TransactionSigner ): Transaction = { val delegDatum = delegationUtxo.output.inlineDatum.get.to[DelegationDatum] val attrTn = DecentralizedIdentityValidator.attributeTokenName( delegDatum.identityTokenName, key ) val attrAsset = AssetName(attrTn) val datum = AttributeDatum( identityTokenName = delegDatum.identityTokenName, key = key, value = value ) def buildMintRedeemer(tx: Transaction): Data = { val delegationRefInputIndex = findRefInputIndex(tx, delegationUtxo) val attributeOutIndex = findOutputIndex(tx, scriptAddr, attrTn) MintAction .PublishAttribute(BigInt(delegationRefInputIndex), BigInt(attributeOutIndex)) .toData } TxBuilder(env, evaluator) .references(delegationUtxo) .mint( parameterizedScript, Map(attrAsset -> 1L), buildMintRedeemer ) .requireSignature(AddrKeyHash(delegDatum.delegatePkh.hash)) .validFrom(validFrom) .validTo(validUntil) .payTo(scriptAddr, Value.asset(policyId, attrAsset, 1), datum) .complete(availableUtxos = utxos, changeAddress) .sign(signer) .transaction } /** Revoke an attribute. * * Identity owner burns an attribute token. The identity UTXO is used as a reference input to * verify ownership. */ def revokeAttribute( utxos: Utxos, identityUtxo: Utxo, attributeUtxo: Utxo, changeAddress: Address, signer: TransactionSigner ): Transaction = { val ownerPkh = identityUtxo.output.inlineDatum.get.to[IdentityDatum].ownerPkh.hash val attrDatum = attributeUtxo.output.inlineDatum.get.to[AttributeDatum] val attrTn = DecentralizedIdentityValidator.attributeTokenName( attrDatum.identityTokenName, attrDatum.key ) val attrAsset = AssetName(attrTn) TxBuilder(env, evaluator) .references(identityUtxo) .spend( attributeUtxo, SpendAction.RevokeAttribute, parameterizedScript ) .requireSignature(AddrKeyHash(ownerPkh)) .mint(parameterizedScript, Map(attrAsset -> -1L), MintAction.Burn) .complete(availableUtxos = utxos, changeAddress) .sign(signer) .transaction } } object DecentralizedIdentityTransactions { /** Submit a transaction with retry logic for TOCTOU race conditions. * * When a UTXO is consumed between query time and submission time, the transaction fails with * `UtxoNotAvailable`. This method re-queries UTXOs and rebuilds the transaction on retryable * errors. * * Note: uses `Thread.sleep` for delay, which blocks a thread pool thread. This is * intentionally simple for example code; production code should use a scheduler-based delay. * * @param provider * blockchain provider for querying UTXOs and submitting transactions * @param queryAddress * address to re-query UTXOs from on retry * @param maxRetries * maximum number of retry attempts (default 3) * @param delayMs * delay in milliseconds between retries (default 1000) * @param buildTx * function that builds a transaction from fresh UTXOs * @return * either a submit error or the transaction hash */ def submitWithRetry( provider: BlockchainProvider, queryAddress: Address, maxRetries: Int = 3, delayMs: Long = 1000 )( buildTx: Utxos => Transaction )(using ExecutionContext): Future[Either[SubmitError, TransactionHash]] = { def attempt(retriesLeft: Int): Future[Either[SubmitError, TransactionHash]] = provider.findUtxos(queryAddress).flatMap { case Left(err) => Future.successful( Left(NetworkSubmitError.InternalError(s"Failed to query UTXOs: ${err}")) ) case Right(utxos) => Try(buildTx(utxos)) match case Failure(ex) => Future.successful( Left( NetworkSubmitError.InternalError( s"Failed to build transaction: ${ex.getMessage}", Some(ex) ) ) ) case Success(tx) => provider.submit(tx).flatMap { case right @ Right(_) => Future.successful(right) case left @ Left(err) => if retriesLeft > 0 && isRetryable(err) then Future { Thread.sleep(delayMs) }.flatMap(_ => attempt(retriesLeft - 1)) else Future.successful(left) } } attempt(maxRetries) } private def isRetryable(error: SubmitError): Boolean = error match case _: NetworkSubmitError.ConnectionError => true case _: NetworkSubmitError.InternalError => true case _: NetworkSubmitError.MempoolFull => true case _: NodeSubmitError.UtxoNotAvailable => true case _ => false } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/decentralizedidentity/DecentralizedIdentityValidator.scala ```scala package scalus.examples.decentralizedidentity import scalus.compiler.Compile import scalus.uplc.builtin.{ByteString, Data} import scalus.uplc.builtin.Builtins.blake2b_224 import scalus.uplc.builtin.Data.{FromData, ToData} import scalus.cardano.onchain.plutus.v1.{Credential, IntervalBoundType, PolicyId, PosixTime, PubKeyHash} import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.v3.DataParameterizedValidator // ===== Data types ===== /** Datum for the identity NFT UTXO */ case class IdentityDatum(ownerPkh: PubKeyHash) derives FromData, ToData /** Datum for a delegation token UTXO */ case class DelegationDatum( identityTokenName: ByteString, delegatePkh: PubKeyHash, validFrom: PosixTime, validUntil: PosixTime, delegateType: ByteString ) derives FromData, ToData /** Datum for an attribute token UTXO */ case class AttributeDatum( identityTokenName: ByteString, key: ByteString, value: ByteString ) derives FromData, ToData // ===== Redeemers ===== enum MintAction derives FromData, ToData { case CreateIdentity(seedIndex: BigInt, identityOutIndex: BigInt) case AddDelegate(identityRefInputIndex: BigInt, delegationOutIndex: BigInt) case PublishAttribute(delegationRefInputIndex: BigInt, attributeOutIndex: BigInt) case Burn } enum SpendAction derives FromData, ToData { case TransferOwnership(newOwnerPkh: PubKeyHash, identityOutIndex: BigInt) case RevokeDelegate case RevokeAttribute } /** Decentralized Identity (SSI) validator. * * A single minting policy + spending validator for Self-Sovereign Identity on Cardano. Token name * prefixes distinguish identity, delegation, and attribute tokens: * - Identity: "i" ++ uniqueId (computed off-chain, 29 bytes) * - Delegation: "d" ++ blake2b_224(identityTn ++ delegatePkh) (29 bytes) * - Attribute: "a" ++ blake2b_224(identityTn ++ key) (29 bytes) * * Identity tokens hold an IdentityDatum with the current owner PubKeyHash. Delegation and * attribute tokens are non-transferable (must remain at script address or be burned). */ @Compile object DecentralizedIdentityValidator extends DataParameterizedValidator { // ===== Token naming helpers ===== /** Identity token: "i" ++ uniqueId = 29 bytes */ inline def identityTokenName(uniqueId: ByteString): ByteString = ByteString.fromString("i") ++ uniqueId /** Delegation token: "d" ++ blake2b_224(identityTn ++ delegatePkh) = 29 bytes */ inline def delegationTokenName( identityTn: ByteString, delegatePkh: PubKeyHash ): ByteString = ByteString.fromString("d") ++ blake2b_224(identityTn ++ delegatePkh.hash) /** Attribute token: "a" ++ blake2b_224(identityTn ++ key) = 29 bytes */ inline def attributeTokenName(identityTn: ByteString, key: ByteString): ByteString = ByteString.fromString("a") ++ blake2b_224(identityTn ++ key) // ===== Minting policy ===== inline def mint(param: Data, redeemer: Data, policyId: PolicyId, tx: TxInfo): Unit = { val r = redeemer.to[MintAction] r match { case MintAction.CreateIdentity(seedIndex, identityOutIndex) => // One-shot: must spend the exact parameterized seed UTXO val seedRef = param.to[TxOutRef] val spentInput = tx.inputs.at(seedIndex) require(spentInput.outRef === seedRef, "Must spend the parameterized seed UTxO") // Identity output must be at script address with inline datum val identityOutput = tx.outputs.at(identityOutIndex) identityOutput.datum match case OutputDatum.OutputDatum(d) => d.to[IdentityDatum] case _ => fail("Identity must have inline datum") // Output must be at own script address identityOutput.address.credential match case Credential.ScriptCredential(hash) => require(hash === policyId, "Identity must go to script address") case _ => fail("Identity must go to script address") // Find the identity token name from the output (starts with "i") val idTn = findTokenWithPrefix(identityOutput.value, policyId, "i") // Exactly one identity token minted, nothing else under this policy require( tx.mint.quantityOf(policyId, idTn) === BigInt(1), "Must mint exactly 1 identity token" ) require( tx.mint.tokens(policyId).size === BigInt(1), "Must mint only the identity token" ) case MintAction.AddDelegate(identityRefInputIndex, delegationOutIndex) => // Identity must be present as reference input val identityRefInput = tx.referenceInputs.at(identityRefInputIndex) val identityDatum = identityRefInput.resolved.datum match case OutputDatum.OutputDatum(d) => d.to[IdentityDatum] case _ => fail("Identity ref input must have inline datum") // Must be signed by identity owner require( tx.signatories.exists(_ === identityDatum.ownerPkh), "Must be signed by identity owner" ) // Find the identity token name from the reference input val identityTn = findTokenWithPrefix(identityRefInput.resolved.value, policyId, "i") // Delegation output must be at script address val delegationOutput = tx.outputs.at(delegationOutIndex) delegationOutput.address.credential match case Credential.ScriptCredential(hash) => require(hash === policyId, "Delegation must go to script address") case _ => fail("Delegation must go to script address") // Check delegation datum val delegDatum = delegationOutput.datum match case OutputDatum.OutputDatum(d) => d.to[DelegationDatum] case _ => fail("Delegation must have inline datum") require( delegDatum.identityTokenName === identityTn, "Delegation must reference correct identity" ) // Note: self-delegation (owner == delegate) is intentionally allowed. // The owner cannot publish attributes directly — only delegates can. // Without self-delegation, the owner would have no way to publish attributes // about their own identity. // Build expected token name and check minting val delegTn = delegationTokenName(identityTn, delegDatum.delegatePkh) require( tx.mint.quantityOf(policyId, delegTn) === BigInt(1), "Must mint exactly 1 delegation token" ) require( delegationOutput.value.quantityOf(policyId, delegTn) === BigInt(1), "Delegation output must hold the delegation token" ) require( tx.mint.tokens(policyId).size === BigInt(1), "Must mint only the delegation token" ) case MintAction.PublishAttribute(delegationRefInputIndex, attributeOutIndex) => // Delegation must be present as reference input val delegationRefInput = tx.referenceInputs.at(delegationRefInputIndex) val delegDatum = delegationRefInput.resolved.datum match case OutputDatum.OutputDatum(d) => d.to[DelegationDatum] case _ => fail("Delegation ref input must have inline datum") // Delegation must be at script address (proving it's valid/non-forged) delegationRefInput.resolved.address.credential match case Credential.ScriptCredential(hash) => require(hash === policyId, "Delegation must be at script address") case _ => fail("Delegation must be at script address") // The delegation must actually hold its delegation token. Being at the script // address with a datum-shaped value is not enough — anyone can pay a forged // DelegationDatum there. The token is minted only by AddDelegate (which requires the // identity owner's signature) and burned by RevokeDelegate, so requiring it both // proves the delegation is genuine and makes revocation effective. val delegTn = delegationTokenName(delegDatum.identityTokenName, delegDatum.delegatePkh) require( delegationRefInput.resolved.value.quantityOf(policyId, delegTn) === BigInt(1), "Delegation reference input must hold the delegation token" ) // Must be signed by delegate require( tx.signatories.exists(_ === delegDatum.delegatePkh), "Must be signed by delegate" ) // Check delegation validity: entire tx validity range must fall within delegation period val txStartTime = tx.getValidityStartTime require(txStartTime >= delegDatum.validFrom, "Delegation not yet valid") val txEndTime = tx.validRange.to.boundType match case IntervalBoundType.Finite(t) => t case _ => fail("Transaction must have a finite upper validity bound") require(txEndTime <= delegDatum.validUntil, "Delegation expired") // Attribute output must be at script address val attributeOutput = tx.outputs.at(attributeOutIndex) attributeOutput.address.credential match case Credential.ScriptCredential(hash) => require(hash === policyId, "Attribute must go to script address") case _ => fail("Attribute must go to script address") // Check attribute datum val attrDatum = attributeOutput.datum match case OutputDatum.OutputDatum(d) => d.to[AttributeDatum] case _ => fail("Attribute must have inline datum") require( attrDatum.identityTokenName === delegDatum.identityTokenName, "Attribute must reference correct identity" ) // Build expected token name and check minting val attrTn = attributeTokenName(delegDatum.identityTokenName, attrDatum.key) require( tx.mint.quantityOf(policyId, attrTn) === BigInt(1), "Must mint exactly 1 attribute token" ) require( attributeOutput.value.quantityOf(policyId, attrTn) === BigInt(1), "Attribute output must hold the attribute token" ) require( tx.mint.tokens(policyId).size === BigInt(1), "Must mint only the attribute token" ) case MintAction.Burn => // Ensure all quantities under this policy are negative (only burns allowed) require( tx.mint.tokens(policyId).forall((_, qty) => qty < BigInt(0)), "Burn action must only burn tokens" ) } } // ===== Spending validator ===== inline def spend( param: Data, d: Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val ownInput = tx.findOwnInputOrFail(ownRef) val scriptAddress = ownInput.resolved.address val policyId = scriptAddress.credential match case Credential.ScriptCredential(hash) => hash case _ => fail("Expected script credential") redeemer.to[SpendAction] match { case SpendAction.TransferOwnership(newOwnerPkh, identityOutIndex) => // Only identity tokens can be transferred val datum = d.getOrFail("Datum required").to[IdentityDatum] // Find identity token name on the input val identityTn = findTokenWithPrefix(ownInput.resolved.value, policyId, "i") // Must be signed by current owner require( tx.signatories.exists(_ === datum.ownerPkh), "Must be signed by current owner" ) // Must be signed by new owner (accept transfer) require( tx.signatories.exists(_ === newOwnerPkh), "Must be signed by new owner" ) // Identity token must be returned to script address with new datum val newOutput = tx.outputs.at(identityOutIndex) newOutput.address.credential match case Credential.ScriptCredential(hash) => require(hash === policyId, "Identity must return to script address") case _ => fail("Identity must return to script address") require( newOutput.value.quantityOf(policyId, identityTn) === BigInt(1), "Must return identity token" ) val newDatum = newOutput.datum match case OutputDatum.OutputDatum(d) => d.to[IdentityDatum] case _ => fail("Must have inline datum") require(newDatum.ownerPkh === newOwnerPkh, "Datum must reflect new owner") case SpendAction.RevokeDelegate => // Find the delegation token on the input being spent val delegTn = findTokenWithPrefix(ownInput.resolved.value, policyId, "d") val datum = d.getOrFail("Datum required").to[DelegationDatum] // Must be signed by identity owner: find identity via reference inputs val ownerPkh = findIdentityOwner(tx, policyId, datum.identityTokenName) require( tx.signatories.exists(_ === ownerPkh), "Must be signed by identity owner to revoke" ) // Delegation token must be burned require( tx.mint.quantityOf(policyId, delegTn) === BigInt(-1), "Must burn delegation token" ) case SpendAction.RevokeAttribute => val attrDatum = d.getOrFail("Datum required").to[AttributeDatum] val attrTn = findTokenWithPrefix(ownInput.resolved.value, policyId, "a") // Must be signed by identity owner: find identity via reference inputs val ownerPkh = findIdentityOwner(tx, policyId, attrDatum.identityTokenName) require( tx.signatories.exists(_ === ownerPkh), "Must be signed by identity owner to revoke" ) // Attribute token must be burned require( tx.mint.quantityOf(policyId, attrTn) === BigInt(-1), "Must burn attribute token" ) } } // ===== Helpers ===== /** Find a token at the given UTXO value that starts with a specific prefix. Returns its full * token name. */ private inline def findTokenWithPrefix( value: Value, policyId: PolicyId, prefix: String ): ByteString = { val prefixBs = ByteString.fromString(prefix) val tokenMap = value.tokens(policyId) // Find first token whose name starts with the prefix tokenMap .find { case (tn, qty) => qty > BigInt(0) && tn.take(prefixBs.length) === prefixBs } .map(_._1) .getOrFail("Token with prefix not found") } /** Find the identity owner by looking up the identity token in reference inputs. */ private inline def findIdentityOwner( tx: TxInfo, policyId: PolicyId, identityTokenName: ByteString ): PubKeyHash = { val identityRefInput = tx.referenceInputs .find { txInInfo => txInInfo.resolved.value.quantityOf(policyId, identityTokenName) === BigInt(1) } .getOrFail("Identity reference input not found") identityRefInput.resolved.datum match case OutputDatum.OutputDatum(d) => d.to[IdentityDatum].ownerPkh case _ => fail("Identity must have inline datum") } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/decentralizedidentity/DecentralizedIdentityTest.scala ```scala package scalus.examples.decentralizedidentity import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.ByteString import scalus.uplc.builtin.ByteString.utf8 import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.Context import scalus.cardano.node.Emulator import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.cardano.txbuilder.TxBuilder import scalus.testing.kit.Party.{Alice, Bob, Charles} import scalus.testing.kit.ScalusTest import scalus.testing.kit.TestUtil.{genesisHash, testEnvironment} import scalus.utils.await import java.time.Instant import scala.concurrent.ExecutionContext.Implicits.global class DecentralizedIdentityTest extends AnyFunSuite, ScalusTest { import DecentralizedIdentityTest.{*, given} test( s"DecentralizedIdentity validator size is ${DecentralizedIdentityContract.compiled.script.script.size} bytes" ) { info(s"Validator size: ${DecentralizedIdentityContract.compiled.script.script.size} bytes") } test("Create identity: mints identity NFT at script address") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) val tx = txCreator.createIdentity( utxos = utxos, ownerPkh = Alice.addrKeyHash, changeAddress = Alice.address, signer = Alice.signer ) val result = provider.submit(tx).await() assert(result.isRight, s"Create identity should succeed: $result") // Verify identity NFT at script address val identityOutput = tx.utxos .find(_._2.address == txCreator.scriptAddr) .getOrElse(fail("Identity NFT missing from output")) val datum = identityOutput._2.inlineDatum.get.to[IdentityDatum] assert( datum.ownerPkh.hash == (Alice.addrKeyHash: ByteString), "Datum ownerPkh should match Alice" ) } test("Transfer ownership: old and new owner sign, datum updated") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) // Create identity val createTx = txCreator.createIdentity( utxos = utxos, ownerPkh = Alice.addrKeyHash, changeAddress = Alice.address, signer = Alice.signer ) provider.submit(createTx).await() val identityUtxo = Utxo(createTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // Transfer ownership to Bob val aliceUtxos = provider.findUtxos(Alice.address).await().toOption.get val transferTx = txCreator.transferOwnership( utxos = aliceUtxos, identityUtxo = identityUtxo, newOwnerPkh = Bob.addrKeyHash, changeAddress = Alice.address, ownerSigner = Alice.signer, newOwnerSigner = Bob.signer ) val transferResult = provider.submit(transferTx).await() assert(transferResult.isRight, s"Transfer should succeed: $transferResult") // Verify new owner in datum val newIdentityOutput = transferTx.utxos .find(_._2.address == txCreator.scriptAddr) .getOrElse(fail("Identity NFT missing after transfer")) val newDatum = newIdentityOutput._2.inlineDatum.get.to[IdentityDatum] assert( newDatum.ownerPkh.hash == (Bob.addrKeyHash: ByteString), "Datum ownerPkh should match Bob after transfer" ) } test("Add delegate: owner mints delegation token at script address") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) // Create identity val createTx = txCreator.createIdentity( utxos = utxos, ownerPkh = Alice.addrKeyHash, changeAddress = Alice.address, signer = Alice.signer ) provider.submit(createTx).await() val identityUtxo = Utxo(createTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // Add delegate val aliceUtxos = provider.findUtxos(Alice.address).await().toOption.get val delegateTx = txCreator.addDelegate( utxos = aliceUtxos, identityUtxo = identityUtxo, delegatePkh = Bob.addrKeyHash, validFrom = now, validUntil = oneHourLater, delegateType = utf8"auth", changeAddress = Alice.address, signer = Alice.signer ) val delegateResult = provider.submit(delegateTx).await() assert(delegateResult.isRight, s"Add delegate should succeed: $delegateResult") // Verify delegation token at script address val delegationOutputs = delegateTx.utxos.filter(_._2.address == txCreator.scriptAddr) assert(delegationOutputs.size >= 1, "Should have delegation output at script address") } test("Add delegate fails without owner signature") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) // Create identity val createTx = txCreator.createIdentity( utxos = utxos, ownerPkh = Alice.addrKeyHash, changeAddress = Alice.address, signer = Alice.signer ) provider.submit(createTx).await() val identityUtxo = Utxo(createTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // Try to add delegate with Bob signing instead of Alice (the owner) val bobUtxos = provider.findUtxos(Bob.address).await().toOption.get val delegateTx = txCreator.addDelegate( utxos = bobUtxos, identityUtxo = identityUtxo, delegatePkh = Charles.addrKeyHash, validFrom = now, validUntil = oneHourLater, delegateType = utf8"auth", changeAddress = Bob.address, signer = Bob.signer ) val delegateResult = provider.submit(delegateTx).await() assert(delegateResult.isLeft, "Add delegate without owner sig should fail") } test("Publish attribute: delegate publishes via reference input") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) // Create identity val createTx = txCreator.createIdentity( utxos = utxos, ownerPkh = Alice.addrKeyHash, changeAddress = Alice.address, signer = Alice.signer ) provider.submit(createTx).await() val identityUtxo = Utxo(createTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // Add delegate val aliceUtxos = provider.findUtxos(Alice.address).await().toOption.get val delegateTx = txCreator.addDelegate( utxos = aliceUtxos, identityUtxo = identityUtxo, delegatePkh = Bob.addrKeyHash, validFrom = now, validUntil = oneHourLater, delegateType = utf8"auth", changeAddress = Alice.address, signer = Alice.signer ) provider.submit(delegateTx).await() // Find delegation UTXO (not the identity UTXO) val delegationUtxo = findDelegationUtxo(delegateTx, txCreator) // Publish attribute as delegate (Bob) val bobUtxos = provider.findUtxos(Bob.address).await().toOption.get val publishTx = txCreator.publishAttribute( utxos = bobUtxos, delegationUtxo = delegationUtxo, key = utf8"email", value = utf8"alice@example.com", validFrom = now, validUntil = oneHourLater, changeAddress = Bob.address, signer = Bob.signer ) val publishResult = provider.submit(publishTx).await() assert(publishResult.isRight, s"Publish attribute should succeed: $publishResult") // Verify attribute at script address val attrOutputs = publishTx.utxos.filter(_._2.address == txCreator.scriptAddr) assert(attrOutputs.nonEmpty, "Should have attribute output at script address") val attrDatum = attrOutputs.head._2.inlineDatum.get.to[AttributeDatum] assert(attrDatum.key == utf8"email", "Attribute key should match") assert(attrDatum.value == utf8"alice@example.com", "Attribute value should match") } test("Publish attribute rejects a forged delegation that lacks the delegation token") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) // Attacker (Charles) plants a UTxO at the script address with a forged DelegationDatum // naming himself as delegate for Alice's identity — but mints NO delegation token. Anyone // can pay a datum to a script address, so this needs no owner authorization. Without a // token-possession check, PublishAttribute would trust this fake delegation. val forgedDatum = DelegationDatum( identityTokenName = txCreator.identityTokenName, delegatePkh = PubKeyHash(Charles.addrKeyHash), validFrom = BigInt(now.toEpochMilli), validUntil = BigInt(oneHourLater.toEpochMilli), delegateType = utf8"auth" ) val charlesUtxos = provider.findUtxos(Charles.address).await().toOption.get val plantTx = TxBuilder(env) .payTo(txCreator.scriptAddr, Value.ada(2), forgedDatum) .complete(availableUtxos = charlesUtxos, Charles.address) .sign(Charles.signer) .transaction assert( provider.submit(plantTx).await().isRight, "Planting the forged delegation should succeed" ) val forgedDelegation = Utxo(plantTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // Charles tries to publish an attribute about Alice's identity via the forged delegation. val charlesUtxos2 = provider.findUtxos(Charles.address).await().toOption.get val publishTx = txCreator.publishAttribute( utxos = charlesUtxos2, delegationUtxo = forgedDelegation, key = utf8"email", value = utf8"forged@evil.com", validFrom = now, validUntil = oneHourLater, changeAddress = Charles.address, signer = Charles.signer ) val result = provider.submit(publishTx).await() assert(result.isLeft, s"Publishing via a token-less forged delegation must fail: $result") } test("Revoke delegate: owner burns delegation token") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator( seedUtxo, PlutusScriptEvaluator(env, EvaluatorMode.EvaluateAndComputeCost) ) // Create identity val createTx = txCreator.createIdentity( utxos = utxos, ownerPkh = Alice.addrKeyHash, changeAddress = Alice.address, signer = Alice.signer ) provider.submit(createTx).await() val identityUtxo = Utxo(createTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // Add delegate val aliceUtxos = provider.findUtxos(Alice.address).await().toOption.get val delegateTx = txCreator.addDelegate( utxos = aliceUtxos, identityUtxo = identityUtxo, delegatePkh = Bob.addrKeyHash, validFrom = now, validUntil = oneHourLater, delegateType = utf8"auth", changeAddress = Alice.address, signer = Alice.signer ) provider.submit(delegateTx).await() val delegationUtxo = findDelegationUtxo(delegateTx, txCreator) // Revoke delegate val aliceUtxos2 = provider.findUtxos(Alice.address).await().toOption.get val revokeTx = txCreator.revokeDelegate( utxos = aliceUtxos2, identityUtxo = identityUtxo, delegationUtxo = delegationUtxo, changeAddress = Alice.address, signer = Alice.signer ) val revokeResult = provider.submit(revokeTx).await() assert(revokeResult.isRight, s"Revoke delegate should succeed: $revokeResult") } test("Revoke attribute: owner burns attribute token") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator( seedUtxo, PlutusScriptEvaluator(env, EvaluatorMode.EvaluateAndComputeCost) ) // Create identity val createTx = txCreator.createIdentity( utxos = utxos, ownerPkh = Alice.addrKeyHash, changeAddress = Alice.address, signer = Alice.signer ) provider.submit(createTx).await() val identityUtxo = Utxo(createTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // Add delegate val aliceUtxos = provider.findUtxos(Alice.address).await().toOption.get val delegateTx = txCreator.addDelegate( utxos = aliceUtxos, identityUtxo = identityUtxo, delegatePkh = Bob.addrKeyHash, validFrom = now, validUntil = oneHourLater, delegateType = utf8"auth", changeAddress = Alice.address, signer = Alice.signer ) provider.submit(delegateTx).await() val delegationUtxo = findDelegationUtxo(delegateTx, txCreator) // Publish attribute as delegate (Bob) val bobUtxos = provider.findUtxos(Bob.address).await().toOption.get val publishTx = txCreator.publishAttribute( utxos = bobUtxos, delegationUtxo = delegationUtxo, key = utf8"email", value = utf8"alice@example.com", validFrom = now, validUntil = oneHourLater, changeAddress = Bob.address, signer = Bob.signer ) provider.submit(publishTx).await() val attributeUtxo = findAttributeUtxo(publishTx, txCreator) // Revoke attribute (by owner) val aliceUtxos2 = provider.findUtxos(Alice.address).await().toOption.get val revokeTx = txCreator.revokeAttribute( utxos = aliceUtxos2, identityUtxo = identityUtxo, attributeUtxo = attributeUtxo, changeAddress = Alice.address, signer = Alice.signer ) val revokeResult = provider.submit(revokeTx).await() assert(revokeResult.isRight, s"Revoke attribute should succeed: $revokeResult") } test( "Full lifecycle: create -> delegate -> publish -> revoke attribute -> revoke delegate -> transfer" ) { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator( seedUtxo, PlutusScriptEvaluator(env, EvaluatorMode.EvaluateAndComputeCost) ) // 1. Create identity val createTx = txCreator.createIdentity( utxos = utxos, ownerPkh = Alice.addrKeyHash, changeAddress = Alice.address, signer = Alice.signer ) val createResult = provider.submit(createTx).await() assert(createResult.isRight, s"Create identity should succeed: $createResult") val identityUtxo = Utxo(createTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // 2. Add delegate (Bob) val aliceUtxos1 = provider.findUtxos(Alice.address).await().toOption.get val delegateTx = txCreator.addDelegate( utxos = aliceUtxos1, identityUtxo = identityUtxo, delegatePkh = Bob.addrKeyHash, validFrom = now, validUntil = oneHourLater, delegateType = utf8"auth", changeAddress = Alice.address, signer = Alice.signer ) val delegateResult = provider.submit(delegateTx).await() assert(delegateResult.isRight, s"Add delegate should succeed: $delegateResult") val delegationUtxo = findDelegationUtxo(delegateTx, txCreator) // 3. Publish attribute (by Bob as delegate) val bobUtxos = provider.findUtxos(Bob.address).await().toOption.get val publishTx = txCreator.publishAttribute( utxos = bobUtxos, delegationUtxo = delegationUtxo, key = utf8"email", value = utf8"alice@example.com", validFrom = now, validUntil = oneHourLater, changeAddress = Bob.address, signer = Bob.signer ) val publishResult = provider.submit(publishTx).await() assert(publishResult.isRight, s"Publish attribute should succeed: $publishResult") val attributeUtxo = findAttributeUtxo(publishTx, txCreator) // 4. Revoke attribute (by owner) val aliceUtxos2 = provider.findUtxos(Alice.address).await().toOption.get val revokeAttrTx = txCreator.revokeAttribute( utxos = aliceUtxos2, identityUtxo = identityUtxo, attributeUtxo = attributeUtxo, changeAddress = Alice.address, signer = Alice.signer ) val revokeAttrResult = provider.submit(revokeAttrTx).await() assert(revokeAttrResult.isRight, s"Revoke attribute should succeed: $revokeAttrResult") // 5. Revoke delegate val aliceUtxos3 = provider.findUtxos(Alice.address).await().toOption.get val revokeDelegTx = txCreator.revokeDelegate( utxos = aliceUtxos3, identityUtxo = identityUtxo, delegationUtxo = delegationUtxo, changeAddress = Alice.address, signer = Alice.signer ) val revokeDelegResult = provider.submit(revokeDelegTx).await() assert(revokeDelegResult.isRight, s"Revoke delegate should succeed: $revokeDelegResult") // 6. Transfer ownership to Bob val aliceUtxos4 = provider.findUtxos(Alice.address).await().toOption.get val transferTx = txCreator.transferOwnership( utxos = aliceUtxos4, identityUtxo = identityUtxo, newOwnerPkh = Bob.addrKeyHash, changeAddress = Alice.address, ownerSigner = Alice.signer, newOwnerSigner = Bob.signer ) val transferResult = provider.submit(transferTx).await() assert(transferResult.isRight, s"Transfer should succeed: $transferResult") // Verify final state val finalOutput = transferTx.utxos .find(_._2.address == txCreator.scriptAddr) .getOrElse(fail("Identity NFT missing after transfer")) val finalDatum = finalOutput._2.inlineDatum.get.to[IdentityDatum] assert( finalDatum.ownerPkh.hash == (Bob.addrKeyHash: ByteString), "Final owner should be Bob" ) } test("submitWithRetry: create identity with retry") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) val result = DecentralizedIdentityTransactions .submitWithRetry(provider, Alice.address) { utxos => txCreator.createIdentity(utxos, Alice.addrKeyHash, Alice.address, Alice.signer) } .await() assert(result.isRight, s"submitWithRetry should succeed: $result") } // ===== Helper methods ===== /** Find the delegation UTXO from a transaction (not the identity UTXO) */ private def findDelegationUtxo( tx: Transaction, txCreator: DecentralizedIdentityTransactions ): Utxo = { val delegationEntry = tx.utxos .filter { case (_, out) => out.address == txCreator.scriptAddr && out.value.assets.assets.exists { case (cs, tokens) => cs == txCreator.policyId && tokens.keys.exists { assetName => assetName.bytes.take(1) == ByteString.fromString("d") } } } .headOption .getOrElse(fail("Delegation UTXO not found")) Utxo(delegationEntry) } /** Find the attribute UTXO from a transaction */ private def findAttributeUtxo( tx: Transaction, txCreator: DecentralizedIdentityTransactions ): Utxo = { val attributeEntry = tx.utxos .filter { case (_, out) => out.address == txCreator.scriptAddr && out.value.assets.assets.exists { case (cs, tokens) => cs == txCreator.policyId && tokens.keys.exists { assetName => assetName.bytes.take(1) == ByteString.fromString("a") } } } .headOption .getOrElse(fail("Attribute UTXO not found")) Utxo(attributeEntry) } } object DecentralizedIdentityTest extends ScalusTest { private given env: CardanoInfo = testEnvironment private val compiledContract = DecentralizedIdentityContract.compiled.withErrorTraces // Use slot-derived times so they fall within valid slots for the emulator val now: Instant = env.slotConfig.slotToInstant(0) val oneHourLater: Instant = now.plusSeconds(3600) def createTxCreator( seedUtxo: Utxo, evaluator: PlutusScriptEvaluator = PlutusScriptEvaluator.constMaxBudget(env) ): DecentralizedIdentityTransactions = DecentralizedIdentityTransactions( env = env, evaluator = evaluator, contract = compiledContract, seed = seedUtxo ) def createProvider(): Emulator = { val initialUtxos = Map( Input(genesisHash, 0) -> TransactionOutput.Babbage( Alice.address, Value.ada(5000) ), Input(genesisHash, 1) -> TransactionOutput.Babbage( Alice.address, Value.ada(5000) ), Input(genesisHash, 2) -> TransactionOutput.Babbage( Bob.address, Value.ada(5000) ), Input(genesisHash, 3) -> TransactionOutput.Babbage( Bob.address, Value.ada(5000) ), Input(genesisHash, 4) -> TransactionOutput.Babbage( Charles.address, Value.ada(5000) ) ) Emulator( initialUtxos = initialUtxos, initialContext = Context.testMainnet() ) } } ``` # Example: editablenft ## scalus-examples/jvm/src/main/scala/scalus/examples/editablenft/EditableNftContract.scala ```scala package scalus.examples.editablenft import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 import scalus.uplc.builtin.Data import scalus.cardano.onchain.plutus.v3.TxOutRef /** Blueprint and compiled script for the editable NFT (CIP-68 style) contract. */ object EditableNftContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(EditableNftValidator.validate) lazy val blueprint = Blueprint.plutusV3[TxOutRef, ReferenceNftDatum, SpendRedeemer]( title = "Editable NFT", description = "Reference-NFT metadata contract: a one-shot UTxO mints the NFT, then the holder may " + "edit the on-chain datum until it is sealed, after which the metadata is immutable.", version = "1.0.0", license = Some("Apache-2.0"), // DataParameterizedValidator applies the one-shot TxOutRef parameter as Data on the UPLC // level; the cast only re-labels the phantom type for schema derivation. compiled = compiled.asInstanceOf[PlutusV3[TxOutRef => Data => Unit]] ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/editablenft/EditableNftTransactions.scala ```scala package scalus.examples.editablenft import scalus.uplc.builtin.{ByteString, Data} import scalus.uplc.builtin.Data.toData import scalus.cardano.address.Address import scalus.cardano.ledger.* import scalus.cardano.txbuilder.* import scalus.cardano.onchain.plutus.v3.TxOutRef import scalus.uplc.PlutusV3 import scalus.cardano.onchain.plutus.v3.TxId /** Transaction creator for CIP-68 style editable NFTs. * * Design: * - No owner field in datum - ownership = holding the user token * - To edit: include user token in tx inputs (proves ownership) * - To transfer: just send the user token (no validator needed) * - Uses indexed UTxO pattern for O(1) lookups */ case class EditableNftTransactions( env: CardanoInfo, evaluator: PlutusScriptEvaluator, contract: PlutusV3[Data => Data => Unit], seed: Utxo ) { private val parameterizedScript = { val txOutRef = TxOutRef(TxId(seed.input.transactionId), BigInt(seed.input.index)) contract.apply(txOutRef.toData) } val scriptAddr: Address = parameterizedScript.address(env.network) val policyId: PolicyId = parameterizedScript.script.scriptHash // ===== Helper methods ===== /** Find user NFT UTXO in the given set of UTXOs */ private def findUserNftUtxo(utxos: Utxos, userTokenName: ByteString): Utxo = utxos .find { case (_, out) => out.value.assets.assets.exists { case (cs, tokens) => cs == policyId && tokens.get(AssetName(userTokenName)).exists(_ > 0) } } .map(Utxo.apply) .getOrElse(throw new Exception("User NFT not found in UTXOs")) /** Find input index for a given UTXO in a transaction */ private def findInputIndex(tx: Transaction, utxo: Utxo): Int = tx.body.value.inputs.toSeq.indexWhere { input => input.transactionId == utxo.input.transactionId && input.index == utxo.input.index } /** Find output index by address and asset */ private def findOutputIndex(tx: Transaction, address: Address, asset: ByteString): Int = tx.body.value.outputs.toSeq.indexWhere { output => output.value.address == address && output.value.value.assets.assets.exists { case (cs, tokens) => cs == policyId && tokens.get(AssetName(asset)).exists(_ > 0) } } /** Build a spend redeemer that continues the reference NFT at script address */ private def buildContinuationSpendRedeemer( userNftUtxo: Utxo, refAsset: ByteString )(tx: Transaction): Data = { val userNftInputIndex = findInputIndex(tx, userNftUtxo) val refNftOutputIndex = findOutputIndex(tx, scriptAddr, refAsset) SpendRedeemer.Spend(BigInt(userNftInputIndex), BigInt(refNftOutputIndex)).toData } // ===== Public API ===== /** Mint both reference NFT and user NFT. * * Creates: * - Reference NFT (100 ++ tokenId) at script address with inline datum * - User NFT (222 ++ tokenId) sent to holder's address */ def mint( utxos: Utxos, tokenId: ByteString, initialData: ByteString, holderAddress: Address, changeAddress: Address, signer: TransactionSigner ): Transaction = { val refDatum = ReferenceNftDatum( tokenId = tokenId, data = initialData, isSealed = false ) val refAsset = EditableNftValidator.refNftName(tokenId) val userAsset = EditableNftValidator.userNftName(tokenId) def buildMintRedeemer(tx: Transaction): Data = { val seedIndex = findInputIndex(tx, seed) val refNftOutIndex = findOutputIndex(tx, scriptAddr, refAsset) MintRedeemer.Mint(BigInt(seedIndex), BigInt(refNftOutIndex)).toData } TxBuilder(env, evaluator) .spend(seed) .mint( parameterizedScript, Map(AssetName(refAsset) -> 1L, AssetName(userAsset) -> 1L), buildMintRedeemer ) .payTo(scriptAddr, Value.asset(policyId, AssetName(refAsset), 1), refDatum) .payTo(holderAddress, Value.asset(policyId, AssetName(userAsset), 1)) .complete(availableUtxos = utxos, changeAddress) .sign(signer) .transaction } /** Edit the NFT data. The user token must be in the provided UTXOs. */ def edit( utxos: Utxos, refNftUtxo: Utxo, newData: ByteString, changeAddress: Address, signer: TransactionSigner ): Transaction = { val oldDatum = refNftUtxo.output.inlineDatum.get.to[ReferenceNftDatum] val newDatum = oldDatum.copy(data = newData) val userNftUtxo = findUserNftUtxo(utxos, oldDatum.userNftName) TxBuilder(env, evaluator) .spend( refNftUtxo, buildContinuationSpendRedeemer(userNftUtxo, oldDatum.refNftName), parameterizedScript ) .spend(userNftUtxo) .payTo(scriptAddr, refNftUtxo.output.value, newDatum) .complete(availableUtxos = utxos, changeAddress) .sign(signer) .transaction } /** Seal the NFT, making it immutable. The user token must be in the provided UTXOs. */ def seal( utxos: Utxos, refNftUtxo: Utxo, changeAddress: Address, signer: TransactionSigner ): Transaction = { val oldDatum = refNftUtxo.output.inlineDatum.get.to[ReferenceNftDatum] val newDatum = oldDatum.copy(isSealed = true) val userNftUtxo = findUserNftUtxo(utxos, oldDatum.userNftName) TxBuilder(env, evaluator) .spend( refNftUtxo, buildContinuationSpendRedeemer(userNftUtxo, oldDatum.refNftName), parameterizedScript ) .spend(userNftUtxo) .payTo(scriptAddr, refNftUtxo.output.value, newDatum) .complete(availableUtxos = utxos, changeAddress) .sign(signer) .transaction } /** Burn both reference NFT and user NFT. The user token must be in the provided UTXOs. */ def burn( utxos: Utxos, refNftUtxo: Utxo, changeAddress: Address, signer: TransactionSigner ): Transaction = { val datum = refNftUtxo.output.inlineDatum.get.to[ReferenceNftDatum] val refAsset = AssetName(datum.refNftName) val userAsset = AssetName(datum.userNftName) val userNftUtxo = findUserNftUtxo(utxos, datum.userNftName) def buildBurnSpendRedeemer(tx: Transaction): Data = { val userNftInputIndex = findInputIndex(tx, userNftUtxo) SpendRedeemer.Burn(BigInt(userNftInputIndex)).toData } TxBuilder(env, evaluator) .spend(refNftUtxo, buildBurnSpendRedeemer, parameterizedScript) .spend(userNftUtxo) .mint( parameterizedScript, Map(refAsset -> -1L, userAsset -> -1L), _ => MintRedeemer.Burn.toData ) .complete(availableUtxos = utxos, changeAddress) .sign(signer) .transaction } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/editablenft/EditableNftValidator.scala ```scala package scalus.examples.editablenft import scalus.compiler.Compile import scalus.uplc.builtin.{ByteString, Data} import scalus.uplc.builtin.ByteString.hex import scalus.uplc.builtin.Data.{FromData, ToData} import scalus.cardano.onchain.plutus.v1.{Credential, PolicyId} import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.uplc.builtin.Data.toData import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* case class ReferenceNftDatum( tokenId: ByteString, data: ByteString, isSealed: Boolean ) derives FromData, ToData @Compile object ReferenceNftDatum { extension (self: ReferenceNftDatum) { inline def refNftName: ByteString = EditableNftValidator.refNftName(self.tokenId) inline def userNftName: ByteString = EditableNftValidator.userNftName(self.tokenId) } } enum MintRedeemer derives FromData, ToData { case Mint(seedIndex: BigInt, refNftOutIndex: BigInt) case Burn } enum SpendRedeemer derives FromData, ToData { case Spend(userNftInputIndex: BigInt, refNftOutputIndex: BigInt) case Burn(userNftInputIndex: BigInt) } /** CIP-68 style editable NFT validator. * * Allows editing the data until the NFT is sealed (via [[ReferenceNftDatum.isSealed]]). After * sealing, the data is no longer editable. NFT cannot be unsealed * * Makes sure that 2 assets exists -- one reference asset (ref NFT) holding the data, and the other * asset (user NFT) proving ownership. The editing and sealing can only be done by the owner, and * is ensured by requiring a user NFT */ @Compile object EditableNftValidator extends DataParameterizedValidator { /** Minting policy: creates paired reference and user NFTs. * * Redeemer contains the base token name (tokenId) without label prefix. This enforces that * both tokens are minted as a matching pair: * - Reference NFT: "100" ++ tokenId * - User NFT: "222" ++ tokenId */ inline def mint(param: Data, redeemer: Data, policyId: PolicyId, tx: TxInfo): Unit = { val seed = param.to[TxOutRef] val r = redeemer.to[MintRedeemer] r match { case MintRedeemer.Mint(seedIndex, refNftOutIndex) => // Bind the seed: the input at seedIndex must be the exact parameterized seed UTxO, // not merely some input that exists. Otherwise the one-shot guarantee is defeated // and the same policy can mint unlimited NFTs (uniqueness broken). A wrong index // simply fails the check (fails closed), so it cannot be bypassed. require(tx.inputs.at(seedIndex).outRef === seed, MustSpendSeed) // Find the reference NFT output - must be at script address with inline datum val refNftOutput = tx.outputs.at(refNftOutIndex) // Validate datum structure and content val datum = refNftOutput.datum match case OutputDatum.OutputDatum(d) => d.to[ReferenceNftDatum] case _ => fail(ReferenceNftMustHaveInlineDatum) val refTokenName = EditableNftValidator.refNftName(datum.tokenId) val userTokenName = EditableNftValidator.userNftName(datum.tokenId) refNftOutput.address.credential match case Credential.ScriptCredential(hash) => val policyIdMatches = hash === policyId val exactlyOneRefNft = refNftOutput.value.quantityOf(policyId, refTokenName) === BigInt(1) val isPreserved = policyIdMatches && exactlyOneRefNft require(isPreserved, ReferenceNftMustBePreserved) case _ => fail(ReferenceNftMustBePreserved) // Verify exactly one reference NFT is minted with correct name require( tx.mint.quantityOf(policyId, refTokenName) === BigInt(1), MustMintOneRefNft ) // Verify exactly one user NFT is minted with correct name require( tx.mint.quantityOf(policyId, userTokenName) === BigInt(1), MustMintOneUserNft ) case MintRedeemer.Burn => // The Burn redeemer may only burn. Reject any positive mint under this policy: // otherwise it is a side door around the one-shot seed check in the Mint branch // (an attacker could mint fresh ref/user pairs with this redeemer, never spending // the seed or any script UTxO). The actual "both tokens burned" check lives in the // spend validator, which runs because the reference NFT is spent from the script. val noPositiveMint = tx.mint.toSortedMap.get(policyId) match case Option.Some(tokens) => tokens.values.forall(_ <= BigInt(0)) case Option.None => true require(noPositiveMint, BurnMustNotMint) } } /** Spending validator: enforces edit-until-sealed policy. * * To spend the reference NFT, the user token must be in transaction inputs. */ inline def spend( param: Data, d: Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val datum = d.getOrFail(DatumRequired).to[ReferenceNftDatum] val ownInput = tx.findOwnInputOrFail(ownRef) val scriptAddress = ownInput.resolved.address val policyId = scriptAddress.credential match case Credential.ScriptCredential(hash) => hash case _ => fail(ExpectedScriptCredential) val userTokenName = EditableNftValidator.userNftName(datum.tokenId) val refTokenName = EditableNftValidator.refNftName(datum.tokenId) redeemer.to[SpendRedeemer] match { case SpendRedeemer.Spend(userNftInputIndex, refNftOutputIndex) => { val userTokenInput = tx.inputs.at(userNftInputIndex) val hasUserToken = userTokenInput.resolved.value.quantityOf(policyId, userTokenName) === BigInt(1) require(hasUserToken, MustPresentUserToken) val newOutput = tx.outputs.at(refNftOutputIndex) val correctAddress = newOutput.address === scriptAddress val correctQuantity = newOutput.value.quantityOf(policyId, refTokenName) === BigInt(1) val validContinuation = correctAddress && correctQuantity require(validContinuation, MustReturnRefNft) val newDatum = newOutput.datum match case OutputDatum.OutputDatum(d) => d.to[ReferenceNftDatum] case _ => fail(ContinuationMustHaveInlineDatum) // Sealed policy enforcement if datum.isSealed then // check the entire datum require(newDatum.toData === d.get, SealedNftImmutable) else // just check the token id, rest is ok to change require(newDatum.tokenId === datum.tokenId, TokenIdImmutable) } case SpendRedeemer.Burn(userNftInputIndex) => { val refNftName = datum.refNftName val userNftName = datum.userNftName val isRefNftBurned = tx.mint.quantityOf(policyId, refNftName) === BigInt(-1) require(isRefNftBurned, MustBurnRefNft) val isUserNftBurned = tx.mint.quantityOf(policyId, userNftName) === BigInt(-1) require(isUserNftBurned, MustBurnUserNft) } } } // CIP-67/68 asset name labels: 100 (0x000643b0) = reference token, 222 (0x000de140) = user token. inline def refNftName(tokenId: ByteString): ByteString = Cip68ReferenceLabel ++ tokenId inline def userNftName(tokenId: ByteString): ByteString = Cip68UserLabel ++ tokenId private inline def Cip68ReferenceLabel: ByteString = hex"000643b0" private inline def Cip68UserLabel: ByteString = hex"000de140" // Error messages private inline val MustSpendSeed = "Must spend the seed UTxO" private inline val ReferenceNftMustHaveInlineDatum = "Reference NFT must have an inline datum" private inline val ReferenceNftMustBePreserved = "Reference NFT must go to this script address" private inline val MustMintOneRefNft = "Must mint exactly 1 reference NFT" private inline val MustMintOneUserNft = "Must mint exactly 1 user NFT" private inline val DatumRequired = "Datum required" private inline val ExpectedScriptCredential = "Expected script credential" private inline val MustPresentUserToken = "Must present user token to edit the reference NFT" private inline val MustReturnRefNft = "Must return reference NFT to the script address" private inline val ContinuationMustHaveInlineDatum = "Continuation must have an inline datum" private inline val SealedNftImmutable = "Sealed NFTs are immutable" private inline val TokenIdImmutable = "Token ID is immutable" private inline val MustBurnRefNft = "Must burn the reference NFT" private inline val MustBurnUserNft = "Must burn the user NFT" private inline val BurnMustNotMint = "Burn redeemer must not mint tokens" } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/editablenft/EditableNftValidatorTest.scala ```scala package scalus.examples.editablenft import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.ByteString.utf8 import scalus.uplc.builtin.Data import scalus.uplc.builtin.Data.toData import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.Context import scalus.cardano.node.Emulator import scalus.cardano.onchain.plutus.v3.{TxId, TxOutRef} import scalus.cardano.txbuilder.TxBuilder import scalus.testing.kit.Party.{Alice, Bob} import scalus.testing.kit.ScalusTest import scalus.testing.kit.TestUtil.{genesisHash, testEnvironment} import scalus.utils.await class EditableNftValidatorTest extends AnyFunSuite, ScalusTest { import EditableNftValidatorTest.{*, given} test( s"EditableNft validator size is ${EditableNftContract.compiled.script.script.size} bytes" ) { info(s"Validator size: ${EditableNftContract.compiled.script.script.size} bytes") } test("Mint: successful minting creates paired reference and user NFTs") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) val mintTx = txCreator.mint( utxos = utxos, tokenId = tokenId, initialData = initialData, holderAddress = Alice.address, changeAddress = Alice.address, signer = Alice.signer ) val result = provider.submit(mintTx).await() assert(result.isRight, s"Minting should succeed: $result") // Verify reference NFT at script address val refNftOutput = mintTx.utxos .find(_._2.address == txCreator.scriptAddr) .getOrElse(fail("Reference NFT missing from mint output")) val refDatum = refNftOutput._2.inlineDatum.get.to[ReferenceNftDatum] assert(refDatum.tokenId == tokenId, "Datum tokenId should match") assert(refDatum.data == initialData, "Datum data should match initial data") assert(!refDatum.isSealed, "New NFT should not be sealed") // Verify user NFT at holder address val userTokenName = refDatum.userNftName val userNftOutput = mintTx.utxos.find { case (_, out) => out.address == Alice.address && out.value.assets.assets.exists { case (cs, tokens) => cs == txCreator.policyId && tokens.get(AssetName(userTokenName)).exists(_ > 0) } } assert(userNftOutput.nonEmpty, "User NFT should be at holder address") } test("CIP-68 token names use canonical 4-byte labels (100=ref, 222=user)") { val ref = EditableNftValidator.refNftName(tokenId).toHex val user = EditableNftValidator.userNftName(tokenId).toHex // CIP-67/68: reference token label 100 = 0x000643b0, user token label 222 = 0x000de140. assert(ref.startsWith("000643b0"), s"reference NFT must use CIP-68 label 100, got $ref") assert(user.startsWith("000de140"), s"user NFT must use CIP-68 label 222, got $user") } test("Mint: cannot mint under the same policy without spending the seed") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) // Legitimate first mint consumes the seed. val mintTx = txCreator.mint( utxos = utxos, tokenId = tokenId, initialData = initialData, holderAddress = Alice.address, changeAddress = Alice.address, signer = Alice.signer ) assert(provider.submit(mintTx).await().isRight, "first mint should succeed") // Attacker mints again under the SAME policy (same seed parameter, same policyId) without // spending the seed, pointing seedIndex at an arbitrary owned input. The one-shot guarantee // must reject this. val aliceUtxos = provider.findUtxos(Alice.address).await().toOption.get val decoy = Utxo(aliceUtxos.head) val parameterizedScript = EditableNftContract.compiled.withErrorTraces.apply( TxOutRef(TxId(seedUtxo.input.transactionId), BigInt(seedUtxo.input.index)).toData ) val tokenId2 = utf8"forgery" val refAsset = EditableNftValidator.refNftName(tokenId2) val userAsset = EditableNftValidator.userNftName(tokenId2) val refDatum = ReferenceNftDatum(tokenId2, initialData, isSealed = false) def buildRedeemer(tx: Transaction): Data = { val seedIdx = tx.body.value.inputs.toSeq.indexWhere(i => i.transactionId == decoy.input.transactionId && i.index == decoy.input.index ) val refIdx = tx.body.value.outputs.toSeq.indexWhere(_.value.address == txCreator.scriptAddr) MintRedeemer.Mint(BigInt(seedIdx), BigInt(refIdx)).toData } val attackTx = TxBuilder(env, PlutusScriptEvaluator.constMaxBudget(env)) .spend(decoy) .mint( parameterizedScript, Map(AssetName(refAsset) -> 1L, AssetName(userAsset) -> 1L), buildRedeemer ) .payTo( txCreator.scriptAddr, Value.asset(txCreator.policyId, AssetName(refAsset), 1), refDatum ) .payTo(Alice.address, Value.asset(txCreator.policyId, AssetName(userAsset), 1)) .complete(availableUtxos = aliceUtxos, Alice.address) .sign(Alice.signer) .transaction val result = provider.submit(attackTx).await() assert(result.isLeft, s"minting without spending the seed must fail, got: $result") } test("Mint: cannot mint a pair via the Burn redeemer (empty burn branch side door)") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) val parameterizedScript = EditableNftContract.compiled.withErrorTraces.apply( TxOutRef(TxId(seedUtxo.input.transactionId), BigInt(seedUtxo.input.index)).toData ) val tokenId2 = utf8"forged-via-burn" val refAsset = EditableNftValidator.refNftName(tokenId2) val userAsset = EditableNftValidator.userNftName(tokenId2) // Attacker mints a fresh ref/user pair using the Burn redeemer. The seed is never spent and // no script UTxO is involved, so only the minting policy governs — and its Burn branch must // refuse to mint (positive quantities), otherwise the one-shot seed check is bypassed. val attackTx = TxBuilder(env, PlutusScriptEvaluator.constMaxBudget(env)) .mint( parameterizedScript, Map(AssetName(refAsset) -> 1L, AssetName(userAsset) -> 1L), _ => MintRedeemer.Burn.toData ) .payTo(Alice.address, Value.asset(txCreator.policyId, AssetName(refAsset), 1)) .payTo(Alice.address, Value.asset(txCreator.policyId, AssetName(userAsset), 1)) .complete(availableUtxos = utxos, Alice.address) .sign(Alice.signer) .transaction val result = provider.submit(attackTx).await() assert(result.isLeft, s"minting via the Burn redeemer must fail, got: $result") } test("Lifecycle: mint -> edit -> edit -> seal success") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) // Mint val mintTx = txCreator.mint( utxos = utxos, tokenId = tokenId, initialData = initialData, holderAddress = Alice.address, changeAddress = Alice.address, signer = Alice.signer ) val mintResult = provider.submit(mintTx).await() assert(mintResult.isRight, s"Mint should succeed: $mintResult") var refNftUtxo = Utxo(mintTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // First edit val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val editTx1 = txCreator.edit( utxos = utxos1, refNftUtxo = refNftUtxo, newData = utf8"First edit", changeAddress = Alice.address, signer = Alice.signer ) val editResult1 = provider.submit(editTx1).await() assert(editResult1.isRight, s"First edit should succeed: $editResult1") refNftUtxo = Utxo(editTx1.utxos.find(_._2.address == txCreator.scriptAddr).get) // Second edit val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val editTx2 = txCreator.edit( utxos = utxos2, refNftUtxo = refNftUtxo, newData = utf8"Second edit", changeAddress = Alice.address, signer = Alice.signer ) val editResult2 = provider.submit(editTx2).await() assert(editResult2.isRight, s"Second edit should succeed: $editResult2") refNftUtxo = Utxo(editTx2.utxos.find(_._2.address == txCreator.scriptAddr).get) // Seal val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val sealTx = txCreator.seal( utxos = utxos3, refNftUtxo = refNftUtxo, changeAddress = Alice.address, signer = Alice.signer ) val sealResult = provider.submit(sealTx).await() assert(sealResult.isRight, s"Seal should succeed: $sealResult") // Verify final state val finalDatum = sealTx.utxos .find(_._2.address == txCreator.scriptAddr) .map(_._2.inlineDatum.get.to[ReferenceNftDatum]) .get assert(finalDatum.isSealed, "NFT should be sealed") assert(finalDatum.data == utf8"Second edit", "Data should reflect last edit") } test("Lifecycle: mint -> edit -> edit -> seal -> edit failure") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) // Mint val mintTx = txCreator.mint( utxos = utxos, tokenId = tokenId, initialData = initialData, holderAddress = Alice.address, changeAddress = Alice.address, signer = Alice.signer ) provider.submit(mintTx).await() var refNftUtxo = Utxo(mintTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // First edit val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val editTx1 = txCreator.edit( utxos = utxos1, refNftUtxo = refNftUtxo, newData = utf8"First edit", changeAddress = Alice.address, signer = Alice.signer ) provider.submit(editTx1).await() refNftUtxo = Utxo(editTx1.utxos.find(_._2.address == txCreator.scriptAddr).get) // Second edit val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val editTx2 = txCreator.edit( utxos = utxos2, refNftUtxo = refNftUtxo, newData = utf8"Second edit", changeAddress = Alice.address, signer = Alice.signer ) provider.submit(editTx2).await() refNftUtxo = Utxo(editTx2.utxos.find(_._2.address == txCreator.scriptAddr).get) // Seal val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val sealTx = txCreator.seal( utxos = utxos3, refNftUtxo = refNftUtxo, changeAddress = Alice.address, signer = Alice.signer ) provider.submit(sealTx).await() refNftUtxo = Utxo(sealTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // Try to edit after seal - should fail val utxos4 = provider.findUtxos(Alice.address).await().toOption.get val editTx3 = txCreator.edit( utxos = utxos4, refNftUtxo = refNftUtxo, newData = utf8"Should fail", changeAddress = Alice.address, signer = Alice.signer ) val editResult3 = provider.submit(editTx3).await() assert(editResult3.isLeft, "Edit after seal should fail") } test("Lifecycle: mint -> edit -> transfer -> edit (by Bob) -> seal success") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) val txCreator = createTxCreator(seedUtxo) // Mint (Alice) val mintTx = txCreator.mint( utxos = utxos, tokenId = tokenId, initialData = initialData, holderAddress = Alice.address, changeAddress = Alice.address, signer = Alice.signer ) provider.submit(mintTx).await() var refNftUtxo = Utxo(mintTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // Edit (Alice) val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val editTx1 = txCreator.edit( utxos = utxos1, refNftUtxo = refNftUtxo, newData = utf8"Alice edit", changeAddress = Alice.address, signer = Alice.signer ) provider.submit(editTx1).await() refNftUtxo = Utxo(editTx1.utxos.find(_._2.address == txCreator.scriptAddr).get) // Transfer user token from Alice to Bob val aliceUtxos = provider.findUtxos(Alice.address).await().toOption.get val userTokenName = EditableNftValidator.userNftName(tokenId) val userNftUtxo = aliceUtxos .find { case (_, out) => out.value.assets.assets.exists { case (cs, tokens) => cs == txCreator.policyId && tokens.get(AssetName(userTokenName)).exists(_ > 0) } } .map(Utxo.apply) .get val transferTx = TxBuilder(env, PlutusScriptEvaluator.constMaxBudget(env)) .spend(userNftUtxo) .payTo(Bob.address, Value.asset(txCreator.policyId, AssetName(userTokenName), 1)) .complete(availableUtxos = aliceUtxos, Alice.address) .sign(Alice.signer) .transaction provider.submit(transferTx).await() // Edit (Bob - new owner) val bobUtxos = provider.findUtxos(Bob.address).await().toOption.get val editTx2 = txCreator.edit( utxos = bobUtxos, refNftUtxo = refNftUtxo, newData = utf8"Bob edit", changeAddress = Bob.address, signer = Bob.signer ) val editResult2 = provider.submit(editTx2).await() assert(editResult2.isRight, s"Bob should be able to edit after transfer: $editResult2") refNftUtxo = Utxo(editTx2.utxos.find(_._2.address == txCreator.scriptAddr).get) // Seal (Bob) val bobUtxos2 = provider.findUtxos(Bob.address).await().toOption.get val sealTx = txCreator.seal( utxos = bobUtxos2, refNftUtxo = refNftUtxo, changeAddress = Bob.address, signer = Bob.signer ) val sealResult = provider.submit(sealTx).await() assert(sealResult.isRight, s"Bob should be able to seal: $sealResult") // Verify final state val finalDatum = sealTx.utxos .find(_._2.address == txCreator.scriptAddr) .map(_._2.inlineDatum.get.to[ReferenceNftDatum]) .get assert(finalDatum.isSealed, "NFT should be sealed") assert(finalDatum.data == utf8"Bob edit", "Data should reflect Bob's edit") } test("Burn: successful burn removes both tokens") { val provider = createProvider() val utxos = provider.findUtxos(Alice.address).await().toOption.get val seedUtxo = Utxo(utxos.head) // Burn uses both spend + mint scripts, so we need proper cost evaluation // (constMaxBudget assigns max budget per script, which exceeds tx limit with 2 scripts) val txCreator = createTxCreator( seedUtxo, PlutusScriptEvaluator(env, EvaluatorMode.EvaluateAndComputeCost) ) // Mint val mintTx = txCreator.mint( utxos = utxos, tokenId = tokenId, initialData = initialData, holderAddress = Alice.address, changeAddress = Alice.address, signer = Alice.signer ) provider.submit(mintTx).await() val refNftUtxo = Utxo(mintTx.utxos.find(_._2.address == txCreator.scriptAddr).get) // Burn val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val burnTx = txCreator.burn( utxos = utxos1, refNftUtxo = refNftUtxo, changeAddress = Alice.address, signer = Alice.signer ) assertResult( ExUnits(memory = 133605L, steps = 34935323L) ): burnTx.witnessSet.redeemers.get.value.totalExUnits val burnResult = provider.submit(burnTx).await() assert(burnResult.isRight, s"Burn should succeed: $burnResult") // Verify tokens are burned (no longer exist) val refTokenName = EditableNftValidator.refNftName(tokenId) val userTokenName = EditableNftValidator.userNftName(tokenId) val noRefNft = burnTx.utxos.forall { case (_, out) => !out.value.assets.assets.exists { case (cs, tokens) => cs == txCreator.policyId && tokens.get(AssetName(refTokenName)).exists(_ > 0) } } val noUserNft = burnTx.utxos.forall { case (_, out) => !out.value.assets.assets.exists { case (cs, tokens) => cs == txCreator.policyId && tokens.get(AssetName(userTokenName)).exists(_ > 0) } } assert(noRefNft, "Reference NFT should be burned") assert(noUserNft, "User NFT should be burned") } } object EditableNftValidatorTest extends ScalusTest { private given env: CardanoInfo = testEnvironment private val compiledContract = EditableNftContract.compiled.withErrorTraces def createTxCreator( seedUtxo: Utxo, evaluator: PlutusScriptEvaluator = PlutusScriptEvaluator.constMaxBudget(env) ): EditableNftTransactions = EditableNftTransactions( env = env, evaluator = evaluator, contract = compiledContract, seed = seedUtxo ) // Test data val tokenId: scalus.uplc.builtin.ByteString = utf8"myNFT" val initialData: scalus.uplc.builtin.ByteString = utf8"Hello" def createProvider(): Emulator = { val initialUtxos = Map( Input(genesisHash, 0) -> TransactionOutput.Babbage( Alice.address, Value.ada(5000) ), Input(genesisHash, 1) -> TransactionOutput.Babbage( Alice.address, Value.ada(5000) ), Input(genesisHash, 2) -> TransactionOutput.Babbage( Bob.address, Value.ada(5000) ), Input(genesisHash, 3) -> TransactionOutput.Babbage( Bob.address, Value.ada(5000) ) ) Emulator( initialUtxos = initialUtxos, initialContext = Context.testMainnet() ) } } ``` # Example: escrow ## scalus-examples/jvm/src/main/scala/scalus/examples/escrow/EscrowContract.scala ```scala package scalus.examples.escrow import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 object EscrowContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(EscrowValidator.validate) lazy val blueprint = Blueprint.plutusV3[Config, Action]( title = "Three-party escrow smart contract", description = "Contract that enforces a simple escrow with three actions: Deposit, Pay, Refund", version = "1.0.0", license = None, compiled = compiled ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/escrow/EscrowOffchain.scala ```scala package scalus.examples.escrow import scalus.cardano.address.{Address, ShelleyAddress} import scalus.cardano.ledger.* import scalus.cardano.node.BlockfrostProvider import scalus.cardano.txbuilder.TransactionSigner import scalus.cardano.wallet.hd.HdAccount import scalus.crypto.ed25519.given import scalus.uplc.builtin.Data.toData import scalus.utils.await import scala.concurrent.ExecutionContext.Implicits.global /** Off-chain runner for the Escrow contract on the Cardano preview testnet. * * This runner uses the native Scalus TxBuilder stack (via [[EscrowTransactions]]) together with * the [[BlockfrostProvider]] for UTxO querying and transaction submission. It replaces the * previous bloxbean-based implementation. * * Flow: * 1. Seller initializes the escrow with the initialization amount. * 2. Buyer deposits the escrow amount (contract now holds escrow + initialization). * 3. Buyer releases the payment to the seller. * * Refund (seller returns funds to buyer) is available as [[refund]] but left out of the main flow. * * Required environment variables: * - `BLOCKFROST_API_KEY` — Blockfrost project id for the preview network * - `SELLER_MNEMONIC` — BIP-39 mnemonic for the seller wallet * - `BUYER_MNEMONIC` — BIP-39 mnemonic for the buyer wallet */ object EscrowOffChain { private val blockfrostApiKey = sys.env("BLOCKFROST_API_KEY") private val sellerMnemonic = sys.env("SELLER_MNEMONIC") private val buyerMnemonic = sys.env("BUYER_MNEMONIC") // HD accounts derived from mnemonics (payment key, index 0). private val seller: HdAccount = HdAccount.fromMnemonic(sellerMnemonic, "", 0) private val buyer: HdAccount = HdAccount.fromMnemonic(buyerMnemonic, "", 0) private val sellerSigner = new TransactionSigner(Set(seller.paymentKeyPair)) private val buyerSigner = new TransactionSigner(Set(buyer.paymentKeyPair)) // Blockfrost provider for the preview testnet. Its `cardanoInfo` carries the preview // protocol params and SlotConfig, so we reuse it as the build environment. private val provider: BlockfrostProvider = BlockfrostProvider.preview(blockfrostApiKey).await() private given env: CardanoInfo = provider.cardanoInfo private val contract = EscrowContract.compiled private val scriptAddress: Address = contract.address(env.network) private val txCreator = EscrowTransactions(env = env, contract = contract) private val sellerAddress: ShelleyAddress = seller.baseAddress(env.network) private val buyerAddress: ShelleyAddress = buyer.baseAddress(env.network) /** Poll the script address for the escrow UTxO matching the given datum and lovelace amount. * * Replaces the bloxbean `waitForUtxoWithAmount` polling helper. The escrow flow keeps a single * UTxO at the script address whose lovelace amount changes between phases (initialization-only * after `initialize`, full amount after `deposit`), so matching on both datum and amount is * enough to locate the correct UTxO. */ private def waitForEscrowUtxo( datum: Config, expectedLovelace: Long, maxAttempts: Int = 20, delayMs: Long = 15000 ): Utxo = { val expectedDatumData = datum.toData def attempt(n: Int): Utxo = { println( s"Searching for escrow UTxO with $expectedLovelace lovelace... (Attempt $n/$maxAttempts)" ) val utxos = provider.findUtxos(scriptAddress).await() match case Right(found) => found case Left(error) => sys.error(s"Failed to query script UTxOs: $error") val matching = utxos.find { case (_, output) => val datumMatches = output.inlineDatum.contains(expectedDatumData) val amountMatches = output.value.coin.value == expectedLovelace datumMatches && amountMatches } matching match case Some(pair) => println("Found escrow UTxO!") Utxo(pair) case None if n < maxAttempts => Thread.sleep(delayMs) attempt(n + 1) case None => sys.error( s"Escrow UTxO with datum and $expectedLovelace lovelace not found at " + s"$scriptAddress after $maxAttempts attempts." ) } attempt(1) } /** Submit a transaction and wait for it to be confirmed. */ private def submitAndConfirm(label: String, tx: Transaction): Unit = { println(s"Submitting $label TX (${tx.id.toHex})...") provider.submit(tx).await() match case Right(txHash) => println(s"$label tx submitted successfully: ${txHash.toHex}") println(s"Waiting for $label tx confirmation...") val status = provider.pollForConfirmation(txHash).await() println(s"$label tx status: $status") case Left(error) => sys.error(s"$label tx submission failed: $error") } /** Seller initializes the escrow contract with the initialization amount. */ def initialize(escrowAmount: Long, initializationAmount: Long): Unit = { val utxos = provider.findUtxos(sellerAddress).await() match case Right(found) => found case Left(error) => sys.error(s"Failed to query seller UTxOs: $error") val tx = txCreator.initialize( utxos = utxos, sponsor = sellerAddress, seller = seller.paymentKeyHash, buyer = buyer.paymentKeyHash, escrowAmount = escrowAmount, initializationAmount = initializationAmount, signer = sellerSigner ) submitAndConfirm("Initialize", tx) } /** Buyer deposits the escrow amount onto the initialized contract. * * The buyer is the fee sponsor (matching the test), so the buyer change output satisfies the * validator's required buyer-output constraint. */ def deposit(datum: Config): Unit = { // Before deposit the contract holds only the initialization amount. val escrowUtxo = waitForEscrowUtxo(datum, datum.initializationAmount.toLong) val utxos = provider.findUtxos(buyerAddress).await() match case Right(found) => found case Left(error) => sys.error(s"Failed to query buyer UTxOs: $error") val tx = txCreator.deposit( utxos = utxos, escrowUtxo = escrowUtxo, buyerAddress = buyerAddress, sponsor = buyerAddress, buyer = buyer.paymentKeyHash, signer = buyerSigner ) submitAndConfirm("Deposit", tx) } /** Buyer releases the payment to the seller. * * The buyer is the fee sponsor so the buyer change output is present, and the seller receives * exactly escrowAmount + initializationAmount as the validator requires. */ def pay(datum: Config): Unit = { // After deposit the contract holds the full amount. val fullAmount = (datum.escrowAmount + datum.initializationAmount).toLong val escrowUtxo = waitForEscrowUtxo(datum, fullAmount) val utxos = provider.findUtxos(buyerAddress).await() match case Right(found) => found case Left(error) => sys.error(s"Failed to query buyer UTxOs: $error") val tx = txCreator.pay( utxos = utxos, escrowUtxo = escrowUtxo, sellerAddress = sellerAddress, buyerAddress = buyerAddress, sponsor = buyerAddress, buyer = buyer.paymentKeyHash, signer = buyerSigner ) submitAndConfirm("Pay", tx) } /** Seller refunds the buyer. * * The seller is the fee sponsor here (mirroring the test): the validator enforces that the * buyer receives exactly the escrow amount and the seller receives exactly the initialization * amount, so the seller — not the buyer — must pay the fee out of its own change. */ def refund(datum: Config): Unit = { val fullAmount = (datum.escrowAmount + datum.initializationAmount).toLong val escrowUtxo = waitForEscrowUtxo(datum, fullAmount) val utxos = provider.findUtxos(sellerAddress).await() match case Right(found) => found case Left(error) => sys.error(s"Failed to query seller UTxOs: $error") val tx = txCreator.refund( utxos = utxos, escrowUtxo = escrowUtxo, sellerAddress = sellerAddress, buyerAddress = buyerAddress, sponsor = sellerAddress, seller = seller.paymentKeyHash, signer = sellerSigner ) submitAndConfirm("Refund", tx) } def main(args: Array[String]): Unit = { val escrowAmount = 10_000_000L // 10 ADA val initializationAmount = 2_000_000L // 2 ADA (min UTxO) val datum = Config( scalus.cardano.onchain.plutus.v1.PubKeyHash(seller.paymentKeyHash), scalus.cardano.onchain.plutus.v1.PubKeyHash(buyer.paymentKeyHash), BigInt(escrowAmount), BigInt(initializationAmount) ) println("Escrow Datum:") println(s" Seller: ${datum.seller}") println(s" Buyer: ${datum.buyer}") println(s" Escrow Amount: $escrowAmount lovelace") println(s" Initialization Amount: $initializationAmount lovelace") println(s" Script address: $scriptAddress") println("Step 1: Seller initializes escrow contract") initialize(escrowAmount, initializationAmount) println("Step 2: Buyer deposits payment") deposit(datum) println("Step 3: Buyer releases payment to seller") pay(datum) // println("Step 3 (alt): Seller refunds to buyer") // refund(datum) } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/escrow/EscrowTransactions.scala ```scala package scalus.examples.escrow import scalus.uplc.builtin.Data import scalus.cardano.address.Address import scalus.cardano.ledger.* import scalus.cardano.txbuilder.* import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.uplc.PlutusV3 /** Transaction builder for Escrow contract operations. * * The escrow flow is: * 1. Initialize: Seller creates UTxO with initializationAmount * 2. Deposit: Buyer adds escrowAmount * 3. Pay: Buyer releases payment to seller OR * 4. Refund: Seller returns funds to buyer */ case class EscrowTransactions( env: CardanoInfo, contract: PlutusV3[Data => Unit] ) { private val scriptAddress: Address = contract.address(env.network) private val builder = TxBuilder(env) /** Seller initializes the escrow contract with the initialization amount. * * @param utxos * Available UTxOs for the transaction * @param sponsor * Address to pay fees from * @param seller * Seller's public key hash * @param buyer * Buyer's public key hash * @param escrowAmount * The amount buyer will deposit * @param initializationAmount * The initial amount locked by seller (typically min UTxO) * @param signer * Transaction signer * @return * Signed transaction */ def initialize( utxos: Utxos, sponsor: Address, seller: AddrKeyHash, buyer: AddrKeyHash, escrowAmount: Long, initializationAmount: Long, signer: TransactionSigner ): Transaction = { val datum = Config( PubKeyHash(seller), PubKeyHash(buyer), BigInt(escrowAmount), BigInt(initializationAmount) ) builder .payTo(scriptAddress, Value.lovelace(initializationAmount), datum) .complete(availableUtxos = utxos, sponsor = sponsor) .sign(signer) .transaction } /** Buyer deposits the escrow amount. * * The validator requires: * - Buyer signature * - Exactly one buyer output * - Contract output with escrowAmount + initializationAmount * - Preserved datum * * @param utxos * Available UTxOs for the transaction * @param escrowUtxo * The existing escrow UTxO with initialization amount * @param buyerAddress * Buyer's address for the required output * @param sponsor * Address to pay fees from * @param buyer * Buyer's public key hash (must sign) * @param signer * Transaction signer * @return * Signed transaction */ def deposit( utxos: Utxos, escrowUtxo: Utxo, buyerAddress: Address, sponsor: Address, buyer: AddrKeyHash, signer: TransactionSigner ): Transaction = { val datum = escrowUtxo.output.requireInlineDatum val escrowDatum = datum.to[Config] val totalAmount = (escrowDatum.escrowAmount + escrowDatum.initializationAmount).toLong // Note: The buyer output is created as change by the TxBuilder when sponsor == buyerAddress. // If sponsor != buyerAddress, an explicit buyer output would need to be added. builder .spend(escrowUtxo, Action.Deposit, contract) .requireSignature(buyer) .payTo( scriptAddress, Value.lovelace(totalAmount), datum ) // Continue contract with preserved datum .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Buyer releases payment to seller. * * The validator requires: * - Buyer signature * - Contract fully funded * - Seller receives escrowAmount + initializationAmount * - Both buyer and seller outputs present * * @param utxos * Available UTxOs for the transaction * @param escrowUtxo * The funded escrow UTxO * @param sellerAddress * Seller's address to receive payment * @param buyerAddress * Buyer's address for the required output * @param sponsor * Address to pay fees from * @param buyer * Buyer's public key hash (must sign) * @param signer * Transaction signer * @return * Signed transaction */ def pay( utxos: Utxos, escrowUtxo: Utxo, sellerAddress: Address, buyerAddress: Address, sponsor: Address, buyer: AddrKeyHash, signer: TransactionSigner ): Transaction = { val datum = escrowUtxo.output.requireInlineDatum val escrowDatum = datum.to[Config] val paymentAmount = (escrowDatum.escrowAmount + escrowDatum.initializationAmount).toLong // Note: The buyer output is created as change by the TxBuilder when sponsor == buyerAddress. builder .spend(escrowUtxo, Action.Pay, contract) .requireSignature(buyer) .payTo(sellerAddress, Value.lovelace(paymentAmount)) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Seller refunds to buyer. * * The validator requires: * - Seller signature * - Contract fully funded * - Buyer receives escrowAmount * - Both buyer and seller outputs present * * @param utxos * Available UTxOs for the transaction * @param escrowUtxo * The funded escrow UTxO * @param sellerAddress * Seller's address for the required output * @param buyerAddress * Buyer's address to receive refund * @param sponsor * Address to pay fees from * @param seller * Seller's public key hash (must sign) * @param signer * Transaction signer * @return * Signed transaction */ def refund( utxos: Utxos, escrowUtxo: Utxo, sellerAddress: Address, buyerAddress: Address, sponsor: Address, seller: AddrKeyHash, signer: TransactionSigner ): Transaction = { val datum = escrowUtxo.output.requireInlineDatum val escrowDatum = datum.to[Config] builder .spend(escrowUtxo, Action.Refund, contract) .requireSignature(seller) .payTo(buyerAddress, Value.lovelace(escrowDatum.escrowAmount.toLong)) .payTo(sellerAddress, Value.lovelace(escrowDatum.initializationAmount.toLong)) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/escrow/EscrowValidator.scala ```scala package scalus.examples.escrow import scalus.compiler.Compile import scalus.uplc.builtin.Data import scalus.uplc.builtin.Data.{FromData, ToData} import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.v3.Validator import scalus.cardano.onchain.plutus.prelude.Option.* // Datum case class Config( seller: PubKeyHash, buyer: PubKeyHash, escrowAmount: Lovelace, initializationAmount: Lovelace ) derives FromData, ToData @Compile object Config { given Eq[Config] = Eq.derived } // Redeemer enum Action derives FromData, ToData: case Deposit case Pay case Refund /** Secure exchange of assets between two parties * * The escrow smart contract allows two parties to exchange assets securely. The contract holds the * assets until both parties agree and sign off on the transaction. * * @see * [[https://github.com/blockchain-unica/rosetta-smart-contracts/tree/main/contracts/escrow]] * [[https://meshjs.dev/smart-contracts/escrow]] * [[https://github.com/cardano-foundation/cardano-template-and-ecosystem-monitoring/tree/main/escrow]] */ @Compile object EscrowValidator extends Validator { inline override def spend( datum: Option[Data], redeemer: Data, txInfo: TxInfo, txOutRef: TxOutRef ): Unit = { val receivedData = datum.getOrFail("Datum not found") val escrowDatum: Config = receivedData.to[Config] val action = redeemer.to[Action] val ownInput = txInfo.findOwnInputOrFail(txOutRef).resolved val contractAddress = ownInput.address val contractInputs = txInfo.findOwnInputsByCredential(contractAddress.credential) val contractBalance = Utils.getAdaFromInputs(contractInputs) action match { case Action.Deposit => handleDeposit(escrowDatum, txInfo, contractAddress, contractBalance, receivedData) case Action.Pay => handlePay(escrowDatum, txInfo, contractBalance) case Action.Refund => handleRefund(escrowDatum, txInfo, contractBalance) } } private inline def handleDeposit( escrowDatum: Config, txInfo: TxInfo, contractAddress: Address, contractBalance: Lovelace, receivedData: Data ): Unit = { require( txInfo.isSignedBy(escrowDatum.buyer), "Buyer must sign deposit transaction" ) val buyerOutputs = txInfo.findOwnOutputsByCredential(Credential.PubKeyCredential(escrowDatum.buyer)) val contractOutputs = txInfo.findOwnOutputsByCredential(contractAddress.credential) require(contractOutputs.length === BigInt(1), "Expected exactly one contract output") val contractOutput = contractOutputs.head require(buyerOutputs.length === BigInt(1), "Expected exactly one buyer output") require( contractBalance === escrowDatum.initializationAmount, "Contract must contain only initialization amount before deposit" ) require( Utils.getAdaFromOutputs( contractOutputs ) === escrowDatum.escrowAmount + escrowDatum.initializationAmount, "Contract output must contain exactly escrow amount plus initialization amount" ) contractOutput.datum match { case OutputDatum.OutputDatum(inlineData) => require( inlineData === receivedData, "EscrowDatum must be preserved" ) case _ => fail("Expected inline datum") } } private inline def handlePay( escrowDatum: Config, txInfo: TxInfo, contractBalance: Lovelace ): Unit = { require( contractBalance === escrowDatum.escrowAmount + escrowDatum.initializationAmount, "Contract must be fully funded before payment" ) val buyerOutputs = txInfo.findOwnOutputsByCredential(Credential.PubKeyCredential(escrowDatum.buyer)) val sellerOutputs = txInfo.findOwnOutputsByCredential(Credential.PubKeyCredential(escrowDatum.seller)) require( sellerOutputs.nonEmpty, "Seller outputs must not be empty" ) require( buyerOutputs.nonEmpty, "Buyer outputs must not be empty" ) require( txInfo.isSignedBy(escrowDatum.buyer), "Only buyer can release payment" ) require( Utils.getAdaFromOutputs( sellerOutputs ) === escrowDatum.escrowAmount + escrowDatum.initializationAmount, "Seller must receive exactly escrow amount plus initialization amount" ) } private inline def handleRefund( escrowDatum: Config, txInfo: TxInfo, contractBalance: Lovelace ): Unit = { require( contractBalance === escrowDatum.escrowAmount + escrowDatum.initializationAmount, "Contract must be fully funded before refund" ) val buyerOutputs = txInfo.findOwnOutputsByCredential(Credential.PubKeyCredential(escrowDatum.buyer)) val sellerOutputs = txInfo.findOwnOutputsByCredential(Credential.PubKeyCredential(escrowDatum.seller)) require( sellerOutputs.nonEmpty, "Seller outputs must not be empty" ) require( buyerOutputs.nonEmpty, "Buyer outputs must not be empty" ) require( txInfo.isSignedBy(escrowDatum.seller), "Only seller can issue refund" ) require( Utils.getAdaFromOutputs(buyerOutputs) === escrowDatum.escrowAmount, "Buyer must receive exactly the escrow amount back" ) } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/escrow/EscrowTest.scala ```scala package scalus.examples.escrow import org.scalatest.funsuite.AnyFunSuite import scalus.cardano.ledger.* import scalus.cardano.node.Emulator import scalus.cardano.txbuilder.TxBuilder import scalus.testing.kit.Party.{Alice, Bob, Eve} import scalus.testing.kit.{ScalusTest, TestUtil} import scalus.utils.await /** Tests for the EscrowValidator contract. * * Flow: * 1. Initialize: Alice (seller) creates escrow UTxO with initializationAmount * 2. Deposit: Bob (buyer) deposits escrowAmount * 3. Pay: Bob releases payment to Alice OR * 4. Refund: Alice refunds to Bob */ class EscrowTest extends AnyFunSuite, ScalusTest { private given env: CardanoInfo = TestUtil.testEnvironment private val contract = EscrowContract.compiled.withErrorTraces private val txCreator = EscrowTransactions( env = env, contract = contract ) // Test amounts (in lovelace) private val initializationAmount: Long = 2_000_000L // 2 ADA (min UTxO) private val escrowAmount: Long = 10_000_000L // 10 ADA private val totalAmount: Long = initializationAmount + escrowAmount private def createProvider(): Emulator = Emulator.withAddresses(Seq(Alice.address, Bob.address, Eve.address)) /** Helper: Initialize escrow contract. Returns the escrow UTxO. */ private def initialize(provider: Emulator): Utxo = { val utxos = provider.findUtxos(address = Alice.address).await().toOption.get val initTx = txCreator.initialize( utxos = utxos, sponsor = Alice.address, seller = Alice.addrKeyHash, buyer = Bob.addrKeyHash, escrowAmount = escrowAmount, initializationAmount = initializationAmount, signer = Alice.signer ) assert(provider.submit(initTx).await().isRight, "Initialize tx failed") val escrowUtxo = initTx.utxos.find { case (_, txOut) => txOut.address == contract.address(env.network) }.get Utxo(escrowUtxo) } /** Helper: Initialize and deposit. Returns the funded escrow UTxO. */ private def initializeAndDeposit(provider: Emulator): Utxo = { val escrowUtxo = initialize(provider) val utxos = provider.findUtxos(Bob.address).await().toOption.get val depositTx = txCreator.deposit( utxos = utxos, escrowUtxo = escrowUtxo, buyerAddress = Bob.address, sponsor = Bob.address, buyer = Bob.addrKeyHash, signer = Bob.signer ) assert(provider.submit(depositTx).await().isRight, "Deposit tx failed") // Find the new funded escrow UTxO val fundedUtxo = depositTx.utxos.find { case (_, txOut) => txOut.address == contract.address(env.network) }.get Utxo(fundedUtxo) } // --- Contract Size --- test(s"Escrow validator size is ${EscrowContract.compiled.script.script.size} bytes") { assert(EscrowContract.compiled.script.script.size > 0) } // --- Initialize Tests --- test("Initialize: seller creates escrow UTxO") { val provider = createProvider() val escrowUtxo = initialize(provider) assert(escrowUtxo.output.value.coin.value == initializationAmount) } // --- Deposit Tests --- test("Deposit: buyer deposits escrow amount") { val provider = createProvider() val escrowUtxo = initialize(provider) val utxos = provider.findUtxos(Bob.address).await().toOption.get val depositTx = txCreator.deposit( utxos = utxos, escrowUtxo = escrowUtxo, buyerAddress = Bob.address, sponsor = Bob.address, buyer = Bob.addrKeyHash, signer = Bob.signer ) assertResult( ExUnits(memory = 176520L, steps = 57131676L) ): depositTx.witnessSet.redeemers.get.value.totalExUnits val result = provider.submit(depositTx).await() assert(result.isRight, s"Deposit tx failed: $result") // Verify the contract UTxO now has the full amount val fundedUtxo = depositTx.utxos.find { case (_, txOut) => txOut.address == contract.address(env.network) }.get assert(fundedUtxo._2.value.coin.value == totalAmount) } test("Deposit fails: wrong signer (not buyer)") { val provider = createProvider() val escrowUtxo = initialize(provider) val utxos = provider.findUtxos(Eve.address).await().toOption.get assertScriptFail("Buyer must sign deposit transaction") { txCreator.deposit( utxos = utxos, escrowUtxo = escrowUtxo, buyerAddress = Eve.address, sponsor = Eve.address, buyer = Eve.addrKeyHash, // Wrong signer - should be Bob signer = Eve.signer ) } } test("Deposit: already-funded contract is rejected (no idempotent re-deposit)") { val provider = createProvider() val fundedUtxo = initializeAndDeposit(provider) val utxos = provider.findUtxos(Bob.address).await().toOption.get // The deposit precondition requires the contract to hold ONLY the initialization amount // beforehand (`contractBalance === initializationAmount`). A re-deposit onto an already // funded contract (balance = escrowAmount + initializationAmount) must therefore fail. assertScriptFail("Contract must contain only initialization amount before deposit") { txCreator.deposit( utxos = utxos, escrowUtxo = fundedUtxo, buyerAddress = Bob.address, sponsor = Bob.address, buyer = Bob.addrKeyHash, signer = Bob.signer ) } } test("Deposit: works when initializationAmount equals escrowAmount") { val provider = createProvider() val utxos0 = provider.findUtxos(Alice.address).await().toOption.get // With the old `contractBalance != escrowAmount` precondition, an escrow whose // initializationAmount equals its escrowAmount could never be funded (the contract holds // exactly escrowAmount before deposit, so the check failed). The corrected precondition // (=== initializationAmount) funds it fine. val initTx = txCreator.initialize( utxos = utxos0, sponsor = Alice.address, seller = Alice.addrKeyHash, buyer = Bob.addrKeyHash, escrowAmount = escrowAmount, initializationAmount = escrowAmount, // init == escrow signer = Alice.signer ) assert(provider.submit(initTx).await().isRight, "init failed") val escrowUtxo = Utxo( initTx.utxos.find(_._2.address == contract.address(env.network)).get ) val utxos1 = provider.findUtxos(Bob.address).await().toOption.get val depositTx = txCreator.deposit( utxos = utxos1, escrowUtxo = escrowUtxo, buyerAddress = Bob.address, sponsor = Bob.address, buyer = Bob.addrKeyHash, signer = Bob.signer ) assert( provider.submit(depositTx).await().isRight, "deposit must succeed when init == escrow" ) } // --- Pay Tests --- test("Pay: buyer releases payment to seller") { val provider = createProvider() val fundedUtxo = initializeAndDeposit(provider) val utxos = provider.findUtxos(Bob.address).await().toOption.get val payTx = txCreator.pay( utxos = utxos, escrowUtxo = fundedUtxo, sellerAddress = Alice.address, buyerAddress = Bob.address, sponsor = Bob.address, buyer = Bob.addrKeyHash, signer = Bob.signer ) assertResult( ExUnits(memory = 158466L, steps = 49717528L) ): payTx.witnessSet.redeemers.get.value.totalExUnits val result = provider.submit(payTx).await() assert(result.isRight, s"Pay tx failed: $result") } test("Pay fails: wrong signer (not buyer)") { val provider = createProvider() val fundedUtxo = initializeAndDeposit(provider) val utxos = provider.findUtxos(Eve.address).await().toOption.get // The validator checks buyerOutputs.nonEmpty before checking isSignedBy. // Since Eve's address != Bob's address (the buyer), buyerOutputs is empty. assertScriptFail("Buyer outputs must not be empty") { txCreator.pay( utxos = utxos, escrowUtxo = fundedUtxo, sellerAddress = Alice.address, buyerAddress = Eve.address, sponsor = Eve.address, buyer = Eve.addrKeyHash, // Wrong signer - should be Bob signer = Eve.signer ) } } test("Pay fails: not funded") { val provider = createProvider() val escrowUtxo = initialize(provider) // Not deposited yet val utxos = provider.findUtxos(Bob.address).await().toOption.get assertScriptFail("Contract must be fully funded before payment") { txCreator.pay( utxos = utxos, escrowUtxo = escrowUtxo, sellerAddress = Alice.address, buyerAddress = Bob.address, sponsor = Bob.address, buyer = Bob.addrKeyHash, signer = Bob.signer ) } } test("Pay fails: wrong amount to seller") { val provider = createProvider() val fundedUtxo = initializeAndDeposit(provider) val utxos = provider.findUtxos(Bob.address).await().toOption.get val builder = TxBuilder(env) // Try to pay seller less than required amount assertScriptFail("Seller must receive exactly escrow amount plus initialization amount") { builder .spend(fundedUtxo, Action.Pay, contract.script) .requireSignature(Bob.addrKeyHash) .payTo( Alice.address, Value.ada(5) ) // Wrong: should be escrowAmount + initializationAmount .payTo(Bob.address, Value.ada(1)) .complete(availableUtxos = utxos, sponsor = Bob.address) .sign(Bob.signer) .transaction } } // --- Refund Tests --- test("Refund: seller refunds to buyer") { val provider = createProvider() val fundedUtxo = initializeAndDeposit(provider) val utxos = provider.findUtxos(Alice.address).await().toOption.get val refundTx = txCreator.refund( utxos = utxos, escrowUtxo = fundedUtxo, sellerAddress = Alice.address, buyerAddress = Bob.address, sponsor = Alice.address, seller = Alice.addrKeyHash, signer = Alice.signer ) assertResult( ExUnits(memory = 169314L, steps = 54736607L) ): refundTx.witnessSet.redeemers.get.value.totalExUnits val result = provider.submit(refundTx).await() assert(result.isRight, s"Refund tx failed: $result") } test("Refund fails: wrong signer (not seller)") { val provider = createProvider() val fundedUtxo = initializeAndDeposit(provider) val utxos = provider.findUtxos(Eve.address).await().toOption.get // The validator checks sellerOutputs.nonEmpty before checking isSignedBy. // Since Eve's address != Alice's address (the seller), sellerOutputs is empty. assertScriptFail("Seller outputs must not be empty") { txCreator.refund( utxos = utxos, escrowUtxo = fundedUtxo, sellerAddress = Eve.address, buyerAddress = Bob.address, sponsor = Eve.address, seller = Eve.addrKeyHash, // Wrong signer - should be Alice signer = Eve.signer ) } } test("Refund fails: not funded") { val provider = createProvider() val escrowUtxo = initialize(provider) // Not deposited yet val utxos = provider.findUtxos(Alice.address).await().toOption.get assertScriptFail("Contract must be fully funded before refund") { txCreator.refund( utxos = utxos, escrowUtxo = escrowUtxo, sellerAddress = Alice.address, buyerAddress = Bob.address, sponsor = Alice.address, seller = Alice.addrKeyHash, signer = Alice.signer ) } } test("Refund fails: wrong amount to buyer") { val provider = createProvider() val fundedUtxo = initializeAndDeposit(provider) val utxos = provider.findUtxos(Alice.address).await().toOption.get val builder = TxBuilder(env) // Try to refund buyer less than required amount assertScriptFail("Buyer must receive exactly the escrow amount back") { builder .spend(fundedUtxo, Action.Refund, contract.script) .requireSignature(Alice.addrKeyHash) .payTo(Bob.address, Value.ada(5)) // Wrong: should be escrowAmount (10 ADA) .payTo(Alice.address, Value.ada(1)) .complete(availableUtxos = utxos, sponsor = Alice.address) .sign(Alice.signer) .transaction } } } ``` # Example: factory ## scalus-examples/jvm/src/main/scala/scalus/examples/factory/Factory.scala ```scala package scalus.examples.factory import scalus.compiler.Compile import scalus.* import scalus.uplc.builtin.Builtins import scalus.uplc.builtin.{ByteString, Data, FromData, ToData} import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.ByteString.given import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* /** Product datum stored in each product UTxO. * * @param tag * Application-specific tag for this product * @param creator * The public key hash of the creator who owns this product */ case class ProductDatum( tag: ByteString, creator: PubKeyHash ) derives FromData, ToData /** Redeemer for the factory minting policy. * * @note * `Create` includes a `seedUtxo` for one-shot minting: the seed must be consumed in the * transaction, guaranteeing that the derived token name is globally unique. */ enum FactoryAction derives FromData, ToData: case Create(tag: ByteString, seedUtxo: TxOutRef) case Destroy /** Factory Pattern for Cardano UTxO Model * * A minting policy acts as a factory: "creating a product" means minting a unique NFT and * producing a UTxO with a [[ProductDatum]] at a spending validator address. "Destroying a product" * means burning the NFT and consuming the UTxO. * * The token name is `blake2b_256(serialiseData(seedUtxo))`, where `seedUtxo` is a one-shot UTxO * reference consumed during creation. Since each UTxO can only be spent once, this guarantees * globally unique token names and prevents duplicate products. * * @see * [[https://github.com/blockchain-unica/rosetta-smart-contracts/tree/main/contracts/factory]] */ @Compile object Factory { /** Compute the expected token name for a product from its seed UTxO. * * The seed UTxO must be consumed in the minting transaction, ensuring one-shot uniqueness. * * @param seedUtxo * The UTxO reference consumed to seed this product's unique identity * @return * blake2b_256(serialiseData(seedUtxo)) */ def computeTokenName(seedUtxo: TxOutRef): TokenName = Builtins.blake2b_256(Builtins.serialiseData(seedUtxo.toData)) /** Validate product creation (minting policy logic for `Create`). * * Checks: * - Tx is signed by the creator * - The seed UTxO is consumed as an input (one-shot guarantee) * - Exactly 1 token minted under this policy * - Token name matches `blake2b_256(serialiseData(seedUtxo))` * - A product output exists at `spendingScriptHash` with the minted NFT and correct inline * datum * * @param creator * The creator's public key hash (first signatory) * @param tag * The product tag from the redeemer * @param seedUtxo * The seed UTxO reference for one-shot minting * @param policyId * This minting policy's hash * @param spendingScriptHash * The spending validator address where products must be locked * @param tx * The transaction info */ def validateCreate( tag: ByteString, seedUtxo: TxOutRef, policyId: PolicyId, spendingScriptHash: ValidatorHash, tx: TxInfo ): Unit = { // Seed UTxO must be consumed (one-shot guarantee) require(tx.inputs.exists(_.outRef === seedUtxo), SeedUtxoMustBeConsumed) // Compute expected token name from seed UTxO val expectedTokenName = computeTokenName(seedUtxo) // Check exactly 1 token minted under this policy with the correct name val mintedTokens = tx.mint.toSortedMap.get(policyId).getOrFail(NoTokensMinted) val (tokenName, quantity) = mintedTokens.toList match case List.Cons(pair, List.Nil) => pair case _ => fail(MustMintExactlyOneToken) require(tokenName === expectedTokenName, WrongTokenName) require(quantity === BigInt(1), MustMintExactlyOneToken) // Find a product output at the spending script address with the NFT and correct datum val scriptCred = Credential.ScriptCredential(spendingScriptHash) val productOutput = tx.outputs .find { output => output.address.credential === scriptCred && output.value.quantityOf(policyId, expectedTokenName) === BigInt(1) } .getOrFail(MissingProductOutput) // Verify inline datum and authorize against the product's own creator. The creator is taken // from the product datum (not the first signatory) so it is order-independent and the // signature check is meaningful: a product can't be created attributed to a non-signer. productOutput.datum match case OutputDatum.OutputDatum(datum) => val productDatum = datum.to[ProductDatum] require(productDatum.tag === tag, DatumTagMismatch) require(tx.isSignedBy(productDatum.creator), CreatorMustSign) case _ => fail(MissingInlineDatum) } /** Validate product destruction (minting policy logic for `Destroy`). * * Checks exactly 1 token burned (qty = -1) under this policy. * * @note * Authorization is enforced by the spending validator ([[validateSpend]]): burning the NFT * requires spending the product UTxO that holds it, which requires the product's * `datum.creator` to sign. So no separate creator check is needed (or meaningful) here — the * old `isSignedBy(signatories.head)` was vacuous (head is always a signatory). Token-name * validation is likewise handled by [[validateSpend]]. * * @param policyId * This minting policy's hash * @param tx * The transaction info */ def validateDestroy( policyId: PolicyId, tx: TxInfo ): Unit = { // Check exactly 1 token burned under this policy val mintedTokens = tx.mint.toSortedMap.get(policyId).getOrFail(NoTokensMinted) val (_, quantity) = mintedTokens.toList match case List.Cons(pair, List.Nil) => pair case _ => fail(MustBurnExactlyOneToken) require(quantity === BigInt(-1), MustBurnExactlyOneToken) } /** Validate spending a product UTxO. * * Checks: * - Tx is signed by the creator from the datum * - The product's NFT (extracted from own input) is burned in this tx * * @param datum * The product datum * @param factoryPolicyId * The factory minting policy hash * @param ownInputValue * The value of the input being spent (used to find the factory NFT) * @param tx * The transaction info */ def validateSpend( datum: ProductDatum, factoryPolicyId: PolicyId, ownInputValue: Value, tx: TxInfo ): Unit = { // Creator must sign require(tx.isSignedBy(datum.creator), CreatorMustSign) // Extract the factory NFT token name from our own input val ownFactoryTokens = ownInputValue.toSortedMap.get(factoryPolicyId).getOrFail(NoFactoryToken) val (tokenName, _) = ownFactoryTokens.toList match case List.Cons(pair, List.Nil) => pair case _ => fail(MustHaveExactlyOneFactoryToken) // The NFT must be burned (qty = -1) val burnQty = tx.mint.quantityOf(factoryPolicyId, tokenName) require(burnQty === BigInt(-1), ProductNFTMustBeBurned) } inline val CreatorMustSign = "Creator must sign the transaction" inline val SeedUtxoMustBeConsumed = "Seed UTxO must be consumed" inline val NoTokensMinted = "No tokens minted under this policy" inline val MustMintExactlyOneToken = "Must mint exactly one token" inline val WrongTokenName = "Token name does not match expected hash" inline val MissingProductOutput = "No product output found at spending script" inline val MissingInlineDatum = "Product output must have an inline datum" inline val DatumTagMismatch = "Product datum tag does not match" inline val MustBurnExactlyOneToken = "Must burn exactly one token" inline val NoFactoryToken = "No factory token found in input" inline val MustHaveExactlyOneFactoryToken = "Input must have exactly one factory token" inline val ProductNFTMustBeBurned = "Product NFT must be burned to spend" } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/factory/FactoryExample.scala ```scala package scalus.examples.factory import scalus.compiler.Compile import scalus.* import scalus.uplc.builtin.Data import scalus.compiler.Options import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.v3.Validator import scalus.uplc.PlutusV3 /** Factory Pattern Example — a combined minting + spending validator. * * '''Minting (factory):''' * - `Create(tag, seedUtxo)`: mints a product NFT (one-shot via seed UTxO) and locks a product * UTxO at this script's address * - `Destroy`: burns a product NFT * * '''Spending (product):''' * - Consuming a product UTxO requires burning the NFT and signing by the creator * * The spending validator address is the same as the minting policy hash (single script), so the * `spendingScriptHash` parameter passed to `validateCreate` is the policy's own hash. * * @see * [[https://github.com/blockchain-unica/rosetta-smart-contracts/tree/main/contracts/factory]] */ @Compile object FactoryExample extends Validator { inline override def mint(redeemer: Data, policyId: PolicyId, tx: TxInfo): Unit = { val action = redeemer.to[FactoryAction] action match case FactoryAction.Create(tag, seedUtxo) => Factory.validateCreate( tag = tag, seedUtxo = seedUtxo, policyId = policyId, spendingScriptHash = policyId, // same script for minting and spending tx = tx ) case FactoryAction.Destroy => Factory.validateDestroy( policyId = policyId, tx = tx ) } inline override def spend( datum: Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val productDatum = datum.getOrFail("Datum required").to[ProductDatum] // Derive the factory policy from our own script address val ownInput = tx.findOwnInputOrFail(ownRef) val factoryPolicyId = ownInput.resolved.address.credential.scriptOption .getOrFail("Own address must be Script") Factory.validateSpend( datum = productDatum, factoryPolicyId = factoryPolicyId, ownInputValue = ownInput.resolved.value, tx = tx ) } } object FactoryContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(FactoryExample.validate) lazy val blueprint = Blueprint.plutusV3[ProductDatum, FactoryAction]( title = "Factory", description = "Factory pattern: a combined minting + spending validator. " + "Create mints a one-shot product NFT via a seed UTxO and locks a product UTxO " + "at the script address. Destroy / spend burn the NFT with the creator's signature.", version = "1.0.0", license = Some("Apache-2.0"), compiled = compiled ) } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/factory/FactoryTest.scala ```scala package scalus.examples.factory import org.scalatest.funsuite.AnyFunSuite import scalus.* import scalus.uplc.builtin.ByteString import scalus.uplc.builtin.ByteString.given import scalus.uplc.builtin.Data.toData import scalus.cardano.onchain.RequirementError import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.testing.kit.EvalTestKit class FactoryTest extends AnyFunSuite with EvalTestKit with scalus.cardano.onchain.plutus.v3.ArbitraryInstances { // --- computeTokenName --- test("computeTokenName is deterministic") { assertEval { val seed = TxOutRef( TxId( ByteString.fromHex( "1111111111111111111111111111111111111111111111111111111111111111" ) ), BigInt(0) ) val tn1 = Factory.computeTokenName(seed) val tn2 = Factory.computeTokenName(seed) tn1 === tn2 } } test("computeTokenName differs for different seed UTxOs") { assertEval { val seed1 = TxOutRef( TxId( ByteString.fromHex( "1111111111111111111111111111111111111111111111111111111111111111" ) ), BigInt(0) ) val seed2 = TxOutRef( TxId( ByteString.fromHex( "2222222222222222222222222222222222222222222222222222222222222222" ) ), BigInt(0) ) !(Factory.computeTokenName(seed1) === Factory.computeTokenName(seed2)) } } test("computeTokenName differs for different output indices") { assertEval { val seed1 = TxOutRef( TxId( ByteString.fromHex( "1111111111111111111111111111111111111111111111111111111111111111" ) ), BigInt(0) ) val seed2 = TxOutRef( TxId( ByteString.fromHex( "1111111111111111111111111111111111111111111111111111111111111111" ) ), BigInt(1) ) !(Factory.computeTokenName(seed1) === Factory.computeTokenName(seed2)) } } // --- validateCreate --- test("validateCreate succeeds with correct mint, datum, output, and seed UTxO") { assertEvalSuccess { val creator = PubKeyHash( ByteString.fromHex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") ) val tag = ByteString.fromHex("cafebabe") val policyId = ByteString.fromHex( "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" ) val seedUtxo = TxOutRef( TxId( ByteString.fromHex( "1111111111111111111111111111111111111111111111111111111111111111" ) ), BigInt(0) ) val expectedTokenName = Factory.computeTokenName(seedUtxo) val productDatum = ProductDatum(tag, creator) val tx = TxInfo( inputs = List( TxInInfo( outRef = seedUtxo, resolved = TxOut( address = Address( Credential.PubKeyCredential(creator), Option.None ), value = Value.lovelace(BigInt(5000000)) ) ) ), outputs = List( TxOut( address = Address( Credential.ScriptCredential(policyId), Option.None ), value = Value .lovelace(BigInt(2000000)) + Value(policyId, expectedTokenName, BigInt(1)), datum = OutputDatum.OutputDatum(productDatum.toData), referenceScript = Option.None ) ), mint = Value(policyId, expectedTokenName, BigInt(1)), signatories = List(creator), id = TxId( ByteString.fromHex( "0000000000000000000000000000000000000000000000000000000000000000" ) ) ) Factory.validateCreate(tag, seedUtxo, policyId, policyId, tx) } } test("validateCreate fails when creator has not signed") { assertEvalFailsWithMessage[RequirementError](Factory.CreatorMustSign) { val creator = PubKeyHash( ByteString.fromHex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") ) val tag = ByteString.fromHex("cafebabe") val policyId = ByteString.fromHex( "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" ) val seedUtxo = TxOutRef( TxId( ByteString.fromHex( "1111111111111111111111111111111111111111111111111111111111111111" ) ), BigInt(0) ) val expectedTokenName = Factory.computeTokenName(seedUtxo) val productDatum = ProductDatum(tag, creator) val tx = TxInfo( inputs = List( TxInInfo( outRef = seedUtxo, resolved = TxOut( address = Address( Credential.PubKeyCredential(creator), Option.None ), value = Value.lovelace(BigInt(5000000)) ) ) ), outputs = List( TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), value = Value .lovelace(BigInt(2000000)) + Value(policyId, expectedTokenName, BigInt(1)), datum = OutputDatum.OutputDatum(productDatum.toData), referenceScript = Option.None ) ), mint = Value(policyId, expectedTokenName, BigInt(1)), signatories = List.empty, // no signatories id = TxId( ByteString.fromHex( "0000000000000000000000000000000000000000000000000000000000000000" ) ) ) Factory.validateCreate(tag, seedUtxo, policyId, policyId, tx) } } test("validateCreate fails when seed UTxO is not consumed") { assertEvalFailsWithMessage[RequirementError](Factory.SeedUtxoMustBeConsumed) { val creator = PubKeyHash( ByteString.fromHex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") ) val tag = ByteString.fromHex("cafebabe") val policyId = ByteString.fromHex( "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" ) val seedUtxo = TxOutRef( TxId( ByteString.fromHex( "1111111111111111111111111111111111111111111111111111111111111111" ) ), BigInt(0) ) val expectedTokenName = Factory.computeTokenName(seedUtxo) val productDatum = ProductDatum(tag, creator) val tx = TxInfo( inputs = List.empty, // seed UTxO NOT in inputs outputs = List( TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), value = Value .lovelace(BigInt(2000000)) + Value(policyId, expectedTokenName, BigInt(1)), datum = OutputDatum.OutputDatum(productDatum.toData), referenceScript = Option.None ) ), mint = Value(policyId, expectedTokenName, BigInt(1)), signatories = List(creator), id = TxId( ByteString.fromHex( "0000000000000000000000000000000000000000000000000000000000000000" ) ) ) Factory.validateCreate(tag, seedUtxo, policyId, policyId, tx) } } test("validateCreate fails with wrong token name") { assertEvalFailsWithMessage[RequirementError](Factory.WrongTokenName) { val creator = PubKeyHash( ByteString.fromHex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") ) val tag = ByteString.fromHex("cafebabe") val policyId = ByteString.fromHex( "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" ) val seedUtxo = TxOutRef( TxId( ByteString.fromHex( "1111111111111111111111111111111111111111111111111111111111111111" ) ), BigInt(0) ) val wrongTokenName = ByteString.fromHex("1234567890abcdef") val productDatum = ProductDatum(tag, creator) val tx = TxInfo( inputs = List( TxInInfo( outRef = seedUtxo, resolved = TxOut( address = Address( Credential.PubKeyCredential(creator), Option.None ), value = Value.lovelace(BigInt(5000000)) ) ) ), outputs = List( TxOut( address = Address(Credential.ScriptCredential(policyId), Option.None), value = Value.lovelace(BigInt(2000000)) + Value(policyId, wrongTokenName, BigInt(1)), datum = OutputDatum.OutputDatum(productDatum.toData), referenceScript = Option.None ) ), mint = Value(policyId, wrongTokenName, BigInt(1)), signatories = List(creator), id = TxId( ByteString.fromHex( "0000000000000000000000000000000000000000000000000000000000000000" ) ) ) Factory.validateCreate(tag, seedUtxo, policyId, policyId, tx) } } test("validateCreate fails with missing product output") { assertEvalFailsWithMessage[NoSuchElementException](Factory.MissingProductOutput) { val creator = PubKeyHash( ByteString.fromHex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") ) val tag = ByteString.fromHex("cafebabe") val policyId = ByteString.fromHex( "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" ) val seedUtxo = TxOutRef( TxId( ByteString.fromHex( "1111111111111111111111111111111111111111111111111111111111111111" ) ), BigInt(0) ) val expectedTokenName = Factory.computeTokenName(seedUtxo) val tx = TxInfo( inputs = List( TxInInfo( outRef = seedUtxo, resolved = TxOut( address = Address( Credential.PubKeyCredential(creator), Option.None ), value = Value.lovelace(BigInt(5000000)) ) ) ), outputs = List.empty, // no outputs mint = Value(policyId, expectedTokenName, BigInt(1)), signatories = List(creator), id = TxId( ByteString.fromHex( "0000000000000000000000000000000000000000000000000000000000000000" ) ) ) Factory.validateCreate(tag, seedUtxo, policyId, policyId, tx) } } // --- validateDestroy --- test("validateDestroy succeeds with correct burn and signature") { assertEvalSuccess { val creator = PubKeyHash( ByteString.fromHex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") ) val policyId = ByteString.fromHex( "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" ) val someTokenName = ByteString.fromHex("aabbccdd") val tx = TxInfo( inputs = List.empty, mint = Value(policyId, someTokenName, BigInt(-1)), signatories = List(creator), id = TxId( ByteString.fromHex( "0000000000000000000000000000000000000000000000000000000000000000" ) ) ) Factory.validateDestroy(policyId, tx) } } // Destroy authorization (creator must sign) is enforced by the spending validator, since // burning the NFT requires spending the product UTxO that holds it — see the validateSpend // tests. validateDestroy itself only checks the burn quantity. test("validateDestroy fails when the burn quantity is not exactly -1") { assertEvalFailsWithMessage[RequirementError](Factory.MustBurnExactlyOneToken) { val creator = PubKeyHash( ByteString.fromHex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") ) val policyId = ByteString.fromHex( "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" ) val someTokenName = ByteString.fromHex("aabbccdd") val tx = TxInfo( inputs = List.empty, mint = Value(policyId, someTokenName, BigInt(-2)), // burns 2, not 1 signatories = List(creator), id = TxId( ByteString.fromHex( "0000000000000000000000000000000000000000000000000000000000000000" ) ) ) Factory.validateDestroy(policyId, tx) } } // --- validateSpend --- test("validateSpend succeeds when NFT is burned and creator signs") { assertEvalSuccess { val creator = PubKeyHash( ByteString.fromHex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") ) val tag = ByteString.fromHex("cafebabe") val factoryPolicyId = ByteString.fromHex( "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" ) val datum = ProductDatum(tag, creator) val tokenName = ByteString.fromHex( "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd" ) val ownInputValue = Value.lovelace(BigInt(2000000)) + Value(factoryPolicyId, tokenName, BigInt(1)) val tx = TxInfo( inputs = List.empty, mint = Value(factoryPolicyId, tokenName, BigInt(-1)), signatories = List(creator), id = TxId( ByteString.fromHex( "0000000000000000000000000000000000000000000000000000000000000000" ) ) ) Factory.validateSpend(datum, factoryPolicyId, ownInputValue, tx) } } test("validateSpend fails when creator has not signed") { assertEvalFailsWithMessage[RequirementError](Factory.CreatorMustSign) { val creator = PubKeyHash( ByteString.fromHex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") ) val tag = ByteString.fromHex("cafebabe") val factoryPolicyId = ByteString.fromHex( "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" ) val datum = ProductDatum(tag, creator) val tokenName = ByteString.fromHex( "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd" ) val ownInputValue = Value.lovelace(BigInt(2000000)) + Value(factoryPolicyId, tokenName, BigInt(1)) val tx = TxInfo( inputs = List.empty, mint = Value(factoryPolicyId, tokenName, BigInt(-1)), signatories = List.empty, // no signatories id = TxId( ByteString.fromHex( "0000000000000000000000000000000000000000000000000000000000000000" ) ) ) Factory.validateSpend(datum, factoryPolicyId, ownInputValue, tx) } } test("validateSpend fails when NFT is not burned") { assertEvalFailsWithMessage[RequirementError](Factory.ProductNFTMustBeBurned) { val creator = PubKeyHash( ByteString.fromHex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") ) val tag = ByteString.fromHex("cafebabe") val factoryPolicyId = ByteString.fromHex( "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" ) val datum = ProductDatum(tag, creator) val tokenName = ByteString.fromHex( "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd" ) val ownInputValue = Value.lovelace(BigInt(2000000)) + Value(factoryPolicyId, tokenName, BigInt(1)) val tx = TxInfo( inputs = List.empty, mint = Value.zero, // no burn signatories = List(creator), id = TxId( ByteString.fromHex( "0000000000000000000000000000000000000000000000000000000000000000" ) ) ) Factory.validateSpend(datum, factoryPolicyId, ownInputValue, tx) } } test("validateSpend fails when no factory token in input") { assertEvalFailsWithMessage[NoSuchElementException](Factory.NoFactoryToken) { val creator = PubKeyHash( ByteString.fromHex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") ) val tag = ByteString.fromHex("cafebabe") val factoryPolicyId = ByteString.fromHex( "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" ) val datum = ProductDatum(tag, creator) val ownInputValue = Value.lovelace(BigInt(2000000)) // no factory NFT val tx = TxInfo( inputs = List.empty, mint = Value.zero, signatories = List(creator), id = TxId( ByteString.fromHex( "0000000000000000000000000000000000000000000000000000000000000000" ) ) ) Factory.validateSpend(datum, factoryPolicyId, ownInputValue, tx) } } } ``` # Example: linkedlist ## scalus-examples/jvm/src/main/scala/scalus/examples/linkedlist/LinkedListContract.scala ```scala package scalus.examples.linkedlist import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 import scalus.uplc.builtin.Data /** Blueprint and compiled script for the on-chain linked-list contract. */ object LinkedListContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(LinkedListValidator.validate) lazy val blueprint = Blueprint.plutusV3[ListConfig, ListAction]( title = "On-chain linked list", description = "Sorted on-chain associative linked list parameterized by a ListConfig. Init/Insert/" + "Remove operations maintain ordering and node-pointer integrity via the minting " + "policy; spending guards continuation of node UTxOs.", version = "1.0.0", license = Some("Apache-2.0"), // DataParameterizedValidator applies the ListConfig parameter as Data on the UPLC level; the // cast only re-labels the phantom type for schema derivation. compiled = compiled.asInstanceOf[PlutusV3[ListConfig => Data => Unit]] ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/linkedlist/LinkedListOffchain.scala ```scala package scalus.examples.linkedlist import scalus.uplc.builtin.{ByteString, Data} import scalus.uplc.builtin.ByteString.given import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.Builtins.appendByteString import scalus.cardano.address.Address import scalus.cardano.ledger.* import scalus.cardano.node.BlockchainReader import scalus.cardano.txbuilder.* import scalus.uplc.PlutusV3 import scalus.patterns.{Element, ElementData, NodeKey} import scalus.cardano.onchain.plutus.prelude.Option as OnchainOption import scalus.cardano.onchain.plutus.prelude.Option.{None as OnchainNone, Some as OnchainSome} import scalus.examples.linkedlist.ListConfig import scala.concurrent.Future /** Off-chain transaction builder for the linked-list example. * * A single [[PlutusV3]] script serves as both minting policy and spending validator. `policyId` * and `scriptAddress` are derived from the compiled, applied script. * * @param rootKey * Asset name of the root NFT (max 32 bytes). * @param prefix * Prefix prepended to every node asset name; its length is derived automatically. */ case class LinkedListOffchain( env: CardanoInfo, evaluator: PlutusScriptEvaluator, mintingContract: PlutusV3[Data => Data => Unit], rootKey: ByteString, prefix: ByteString ) { private val prefixLen: Int = prefix.size private val cfg = ListConfig( rootKey = rootKey, prefix = prefix, prefixLen = BigInt(prefixLen) ) private val appliedScript: PlutusV3[Data => Unit] = mintingContract.apply(cfg.toData) val script: Script.PlutusV3 = appliedScript.script val policyId: PolicyId = appliedScript.script.scriptHash val scriptAddress: Address = appliedScript.address(env.network) /** Decodes the [[Element]] datum from a UTxO's inline datum. */ def readElement(utxo: Utxo): Element = utxo.output.inlineDatum .getOrElse(throw new Exception(s"UTxO has no inline datum: $utxo")) .to[Element] /** Finds the root UTxO (the one holding the `rootKey` NFT). */ def findRoot(utxos: Iterable[Utxo]): Utxo = utxos .find { u => u.output.value.assets.assets .get(policyId) .exists(_.contains(AssetName(rootKey))) } .getOrElse(throw new Exception("Root UTxO not found")) /** Finds the node UTxO whose asset name is `prefix ++ key`. */ def findNode(utxos: Iterable[Utxo], key: NodeKey): Utxo = { val assetName = AssetName(appendByteString(prefix, key)) utxos .find { u => u.output.value.assets.assets .get(policyId) .exists(_.contains(assetName)) } .getOrElse(throw new Exception(s"Node UTxO not found for key: $key")) } /** Returns `(key, utxo)` pairs for every element in list order (root first). */ private def readAllWithUtxos(utxos: Iterable[Utxo]): Seq[(NodeKey, Utxo)] = { @annotation.tailrec def loop(link: OnchainOption[NodeKey], acc: Vector[(NodeKey, Utxo)]): Seq[(NodeKey, Utxo)] = link match case OnchainNone => acc case OnchainSome(nextKey) => val utxo = findNode(utxos, nextKey) loop(readElement(utxo).link, acc :+ (nextKey, utxo)) val rootUtxo = findRoot(utxos) loop(readElement(rootUtxo).link, Vector((rootKey, rootUtxo))) } /** Returns `(key, data)` for every element in list order (root first). */ def readAll(utxos: Iterable[Utxo]): Seq[(NodeKey, Data)] = readAllWithUtxos(utxos).map { (key, utxo) => val payload = readElement(utxo).data match case ElementData.Root(d) => d case ElementData.Node(d) => d (key, payload) } /** Returns the correct anchor UTxO for inserting or removing `key`. * * Walks the list and returns the last node whose asset name is strictly less than * `prefix ++ key`, or the root if no such node exists. */ def findAnchorFor(utxos: Iterable[Utxo], key: NodeKey): Utxo = { val ord = summon[Ordering[ByteString]] val newAssetName = appendByteString(prefix, key) // The root is always a valid anchor (it precedes all nodes regardless of byte ordering // between rootKey and prefix ++ key). Among the nodes that follow, we advance the anchor // as long as the node's asset name is strictly less than the new asset name. The last // such node (or the root if none qualify) is the insertion point. val all = readAllWithUtxos(utxos) val rootUtxo = all.head._2 val nodes = all.tail nodes .takeWhile { (k, _) => ord.compare(appendByteString(prefix, k), newAssetName) < 0 } .lastOption .map(_._2) .getOrElse(rootUtxo) } /** Convenience overload of [[readAll(Iterable[Utxo])]] that fetches UTxOs via `reader`. */ def readAll(reader: BlockchainReader): Future[Seq[(NodeKey, Data)]] = { given scala.concurrent.ExecutionContext = reader.executionContext reader.findUtxos(scriptAddress).map { case Right(utxos) => readAll(utxos.map(Utxo.apply).toSeq) case Left(err) => throw new Exception(s"Failed to query UTxOs: $err") } } /** The first (and only expected) AssetName under `policyId` in this UTxO. */ private def listAssetName(utxo: Utxo): AssetName = utxo.output.value.assets.assets .getOrElse(policyId, Map.empty) .keys .headOption .getOrElse(throw new Exception(s"UTxO has no list asset: $utxo")) /** Index of a UTxO in a transaction's inputs. */ private def inputIndex(tx: Transaction, utxo: Utxo): Int = tx.body.value.inputs.toSeq.indexWhere { i => i.transactionId == utxo.input.transactionId && i.index == utxo.input.index } /** Index of an output at `scriptAddress` holding the given `asset`. */ private def outputIndex(tx: Transaction, asset: AssetName): Int = tx.body.value.outputs.indexWhere { o => o.value.address == scriptAddress && o.value.value.hasAsset(policyId, asset) } /** Initializes an empty list: mints the root NFT and sends it to the script address. */ def init( utxos: Utxos, rootData: Data, sponsor: Address, signer: TransactionSigner ): Transaction = { val rootAssetName = AssetName(rootKey) val rootDatum = Element(ElementData.Root(rootData), OnchainOption.None) def buildRedeemer(tx: Transaction): Data = ListAction.Init(BigInt(outputIndex(tx, rootAssetName))).toData TxBuilder(env, evaluator) .mint(script, Map(rootAssetName -> 1L), buildRedeemer) .payTo(scriptAddress, Value.asset(policyId, rootAssetName, 1), rootDatum) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Destroys an empty list: burns the root NFT. */ def deinit( utxos: Utxos, rootUtxo: Utxo, sponsor: Address, signer: TransactionSigner ): Transaction = { val rootAssetName = AssetName(rootKey) def buildRedeemer(tx: Transaction): Data = ListAction.Deinit(BigInt(inputIndex(tx, rootUtxo))).toData TxBuilder(env, evaluator) .spend(rootUtxo, buildRedeemer, script) .mint(script, Map(rootAssetName -> -1L), buildRedeemer) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Inserts a node in ascending order after `anchorUtxo`. */ def insert( utxos: Utxos, anchorUtxo: Utxo, newKey: ByteString, nodeData: Data, sponsor: Address, signer: TransactionSigner ): Transaction = { val newAssetName = AssetName(appendByteString(prefix, newKey)) val anchorDatum = readElement(anchorUtxo) val anchorAssetName = listAssetName(anchorUtxo) val contAnchorDatum = anchorDatum.copy(link = OnchainOption.Some(newKey)) val newElemDatum = Element(ElementData.Node(nodeData), anchorDatum.link) def buildRedeemer(tx: Transaction): Data = ListAction .Insert( BigInt(inputIndex(tx, anchorUtxo)), BigInt(outputIndex(tx, anchorAssetName)), BigInt(outputIndex(tx, newAssetName)) ) .toData TxBuilder(env, evaluator) .spend(anchorUtxo, buildRedeemer, script) .mint(script, Map(newAssetName -> 1L), buildRedeemer) .payTo(scriptAddress, anchorUtxo.output.value, contAnchorDatum) .payTo(scriptAddress, Value.asset(policyId, newAssetName, 1), newElemDatum) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Appends a node at the tail of an unordered list (`anchorUtxo` must have no link). */ def appendUnordered( utxos: Utxos, anchorUtxo: Utxo, newKey: ByteString, nodeData: Data, sponsor: Address, signer: TransactionSigner ): Transaction = { val newAssetName = AssetName(appendByteString(prefix, newKey)) val anchorDatum = readElement(anchorUtxo) val anchorAssetName = listAssetName(anchorUtxo) val contAnchorDatum = anchorDatum.copy(link = OnchainOption.Some(newKey)) val newElemDatum = Element(ElementData.Node(nodeData), OnchainOption.None) def buildRedeemer(tx: Transaction): Data = ListAction .AppendUnordered( BigInt(inputIndex(tx, anchorUtxo)), BigInt(outputIndex(tx, anchorAssetName)), BigInt(outputIndex(tx, newAssetName)) ) .toData TxBuilder(env, evaluator) .spend(anchorUtxo, buildRedeemer, script) .mint(script, Map(newAssetName -> 1L), buildRedeemer) .payTo(scriptAddress, anchorUtxo.output.value, contAnchorDatum) .payTo(scriptAddress, Value.asset(policyId, newAssetName, 1), newElemDatum) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Prepends a node directly after the root (unordered lists). */ def prependUnordered( utxos: Utxos, rootUtxo: Utxo, newKey: ByteString, nodeData: Data, sponsor: Address, signer: TransactionSigner ): Transaction = { val newAssetName = AssetName(appendByteString(prefix, newKey)) val rootDatum = readElement(rootUtxo) val contRootDatum = rootDatum.copy(link = OnchainOption.Some(newKey)) val newElemDatum = Element(ElementData.Node(nodeData), rootDatum.link) def buildRedeemer(tx: Transaction): Data = ListAction .PrependUnordered( BigInt(inputIndex(tx, rootUtxo)), BigInt(outputIndex(tx, AssetName(rootKey))), BigInt(outputIndex(tx, newAssetName)) ) .toData TxBuilder(env, evaluator) .spend(rootUtxo, buildRedeemer, script) .mint(script, Map(newAssetName -> 1L), buildRedeemer) .payTo(scriptAddress, rootUtxo.output.value, contRootDatum) .payTo(scriptAddress, Value.asset(policyId, newAssetName, 1), newElemDatum) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Removes the head node; `newRootData` is written into the root datum (may be unchanged). */ def removeHead( utxos: Utxos, rootUtxo: Utxo, headUtxo: Utxo, newRootData: Data, sponsor: Address, signer: TransactionSigner ): Transaction = { val rootDatum = readElement(rootUtxo) val headDatum = readElement(headUtxo) val headAssetName = listAssetName(headUtxo) val rootAssetName = listAssetName(rootUtxo) val contRootDatum = rootDatum.copy( data = scalus.patterns.ElementData.Root(newRootData), link = headDatum.link ) def buildRedeemer(tx: Transaction): Data = ListAction .RemoveHead( BigInt(inputIndex(tx, rootUtxo)), BigInt(inputIndex(tx, headUtxo)), BigInt(outputIndex(tx, rootAssetName)) ) .toData TxBuilder(env, evaluator) .spend(rootUtxo, buildRedeemer, script) .spend(headUtxo, _ => ListAction.Spend.toData, script) .mint(script, Map(headAssetName -> -1L), buildRedeemer) .payTo(scriptAddress, rootUtxo.output.value, contRootDatum) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Removes a node; `anchorUtxo` must be the element immediately before it. */ def remove( utxos: Utxos, anchorUtxo: Utxo, nodeUtxo: Utxo, sponsor: Address, signer: TransactionSigner ): Transaction = { val anchorDatum = readElement(anchorUtxo) val nodeDatum = readElement(nodeUtxo) val nodeAssetName = listAssetName(nodeUtxo) val anchorAssetName = listAssetName(anchorUtxo) val contAnchorDatum = anchorDatum.copy(link = nodeDatum.link) def buildRedeemer(tx: Transaction): Data = ListAction .Remove( BigInt(inputIndex(tx, anchorUtxo)), BigInt(inputIndex(tx, nodeUtxo)), BigInt(outputIndex(tx, anchorAssetName)) ) .toData TxBuilder(env, evaluator) .spend(anchorUtxo, buildRedeemer, script) .spend(nodeUtxo, _ => ListAction.Spend.toData, script) .mint(script, Map(nodeAssetName -> -1L), buildRedeemer) .payTo(scriptAddress, anchorUtxo.output.value, contAnchorDatum) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/linkedlist/LinkedListValidator.scala ```scala package scalus.examples.linkedlist import scalus.compiler.Compile import scalus.uplc.builtin.{Data, FromData, ToData} import scalus.cardano.onchain.plutus.v1.{Credential, PolicyId} import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.patterns.{LinkedList, NodeKeyPrefix, NodeKeyPrefixLength, RootKey} // Redeemer enum ListAction derives FromData, ToData { case Init(producedOutputIndex: BigInt) case Deinit(rootInputIndex: BigInt) case Insert( anchorInputIndex: BigInt, contAnchorOutputIndex: BigInt, newElemOutputIndex: BigInt ) case AppendUnordered( anchorInputIndex: BigInt, contAnchorOutputIndex: BigInt, newElemOutputIndex: BigInt ) case PrependUnordered( rootInputIndex: BigInt, contRootOutputIndex: BigInt, newElemOutputIndex: BigInt ) case Remove(anchorInputIndex: BigInt, removingInputIndex: BigInt, contAnchorOutputIndex: BigInt) case RemoveHead( rootInputIndex: BigInt, headInputIndex: BigInt, contRootOutputIndex: BigInt ) case SpendForUpdate(elemInputIndex: BigInt, contElemOutputIndex: BigInt) case Spend } // Param case class ListConfig( rootKey: RootKey, prefix: NodeKeyPrefix, prefixLen: NodeKeyPrefixLength ) derives FromData, ToData /** A validator for a singly linked list. * * The script is parameterized by (rootKey, prefix, prefixLen)`. The minting policy ID is derived * at runtime from the `ScriptContext`; it does **not** need to be stored in the configuration. * * The minting policy checks: * - `Init` / `Deinit` structural invariants via [[LinkedList.init]] / [[LinkedList.deinit]]. * - All insert/remove/fold operations via the respective [[LinkedList]] helpers. * * The spending script delegates to the minting policy by checking that list NFTs are being * minted/burnt (coupling pattern) – except for `SpendForUpdate` which verifies only data changes. */ @Compile object LinkedListValidator extends DataParameterizedValidator { inline def mint(param: Data, redeemer: Data, policyId: PolicyId, tx: TxInfo): Unit = { val cfg = param.to[ListConfig] val action = redeemer.to[ListAction] action match { case ListAction.Init(producedIdx) => val producedOutput = tx.outputs.at(producedIdx) LinkedList.init( rootOut = producedOutput, txMint = tx.mint, policyId = policyId, rootKey = cfg.rootKey ) case ListAction.Deinit(rootInputIdx) => val rootInput = tx.inputs.at(rootInputIdx) LinkedList.deinit( rootInput = rootInput, txMint = tx.mint, policyId = policyId, rootKey = cfg.rootKey ) case ListAction.Insert(anchorIdx, contAnchorIdx, newElemIdx) => val anchorInput = tx.inputs.at(anchorIdx) val contAnchorOutput = tx.outputs.at(contAnchorIdx) val newElemOutput = tx.outputs.at(newElemIdx) LinkedList.insert( anchorInput = anchorInput, contAnchorOutput = contAnchorOutput, newElementOutput = newElemOutput, txMint = tx.mint, policyId = policyId, rootKey = cfg.rootKey, prefix = cfg.prefix, prefixLen = cfg.prefixLen ) case ListAction.AppendUnordered(anchorIdx, contAnchorIdx, newElemIdx) => val anchorInput = tx.inputs.at(anchorIdx) val contAnchorOutput = tx.outputs.at(contAnchorIdx) val newElemOutput = tx.outputs.at(newElemIdx) LinkedList.appendUnordered( anchorInput = anchorInput, contAnchorOutput = contAnchorOutput, newElementOutput = newElemOutput, txMint = tx.mint, policyId = policyId, rootKey = cfg.rootKey, prefix = cfg.prefix, prefixLen = cfg.prefixLen ) case ListAction.PrependUnordered(rootIdx, contRootIdx, newElemIdx) => val rootInput = tx.inputs.at(rootIdx) val contRootOutput = tx.outputs.at(contRootIdx) val newElemOutput = tx.outputs.at(newElemIdx) LinkedList.prependUnordered( rootInput = rootInput, contRootOutput = contRootOutput, newElementOutput = newElemOutput, txMint = tx.mint, policyId = policyId, rootKey = cfg.rootKey, prefix = cfg.prefix, prefixLen = cfg.prefixLen ) case ListAction.Remove(anchorIdx, removingIdx, contAnchorIdx) => val anchorInput = tx.inputs.at(anchorIdx) val removingInput = tx.inputs.at(removingIdx) val contAnchorOutput = tx.outputs.at(contAnchorIdx) LinkedList.remove( anchorInput = anchorInput, removingNodeInput = removingInput, contAnchorOutput = contAnchorOutput, txMint = tx.mint, policyId = policyId, rootKey = cfg.rootKey, prefix = cfg.prefix, prefixLen = cfg.prefixLen ) case ListAction.RemoveHead(rootIdx, headIdx, contRootIdx) => val rootInput = tx.inputs.at(rootIdx) val headInput = tx.inputs.at(headIdx) val contRootOutput = tx.outputs.at(contRootIdx) LinkedList.removeHead( rootInput = rootInput, headNodeInput = headInput, contRootOutput = contRootOutput, txMint = tx.mint, policyId = policyId, rootKey = cfg.rootKey, prefix = cfg.prefix, prefixLen = cfg.prefixLen ) case ListAction.SpendForUpdate(_, _) => fail("SpendForUpdate is a spending-only action") case ListAction.Spend => fail("Spend is a spending-only action") } } inline def spend( param: Data, datum: Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val cfg = param.to[ListConfig] val action = redeemer.to[ListAction] val ownInput = tx.findOwnInputOrFail(ownRef, "Own input not found") val nftPolicyId = ownInput.resolved.value.toSortedMap.toList match case List.Cons((_, _), List.Cons((nftPol, _), List.Nil)) => nftPol case _ => fail("Cannot find NFT policy in own UTxO") action match { case ListAction.SpendForUpdate(elemInputIdx, contElemOutputIdx) => val elemInput = tx.inputs.at(elemInputIdx) require(elemInput.outRef === ownRef, "Spend: input outref mismatch") LinkedList.validateElementUpdate( elementInputIndex = elemInputIdx, contElementOutputIndex = contElemOutputIdx, elementInputOutref = ownRef, txInputs = tx.inputs, txOutputs = tx.outputs, txMint = tx.mint, policyId = nftPolicyId, rootKey = cfg.rootKey, prefix = cfg.prefix, prefixLen = cfg.prefixLen ) case _ => LinkedList.requireListTokensMintedOrBurned(nftPolicyId, tx.mint) } } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/linkedlist/LinkedListTest.scala ```scala package scalus.examples.linkedlist import org.scalatest.funsuite.AnyFunSuite import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks import org.scalacheck.Gen import scalus.uplc.builtin.ByteString import scalus.uplc.builtin.ByteString.given import scalus.uplc.builtin.Builtins.appendByteString import scalus.uplc.builtin.Data.toData import scalus.cardano.ledger.* import scalus.cardano.ledger.EvaluatorMode import scalus.cardano.ledger.rules.{Context, PlutusScriptsTransactionMutator} import scalus.cardano.node.Emulator import scalus.testing.kit.Party.{Alice, Bob} import scalus.testing.kit.TestUtil.genesisHash import scalus.testing.kit.ScalusTest import scalus.utils.await class LinkedListTest extends AnyFunSuite, ScalusTest, ScalaCheckPropertyChecks { import LinkedListTest.{*, given} test(s"LinkedListValidator size: ${LinkedListContract.compiled.script.script.size} bytes") { info(s"Validator size: ${LinkedListContract.compiled.script.script.size} bytes") } test("init: valid empty list") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val tx = txCreator.init(utxos, ().toData, Alice.address, Alice.signer) assert(provider.submit(tx).await().isRight, "init should succeed") } test("deinit: valid empty list") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.init(utxos, ().toData, Alice.address, Alice.signer) provider.submit(initTx).await() val rootUtxo = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val deinitTx = txCreator.deinit(utxos2, rootUtxo, Alice.address, Alice.signer) assert(provider.submit(deinitTx).await().isRight, "deinit should succeed") } test("FAIL: deinit when list has a node") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.init(utxos, ().toData, Alice.address, Alice.signer) provider.submit(initTx).await() val rootUtxo = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val appendTx = txCreator.appendUnordered( utxos2, rootUtxo, nodeKey("alpha"), ().toData, Alice.address, Alice.signer ) provider.submit(appendTx).await() // Now try to deinit the list that still has a node -- must fail. // Use constMaxBudget so the invalid tx can be built without running scripts. val (_, badTxCreator) = createSetup(EvaluatorMode.Validate) val rootUtxo2 = Utxo(appendTx.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && out.value.hasAsset(txCreator.policyId, AssetName(rootKeyBytes)) }.get) val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val deinitTx = badTxCreator.deinit(utxos3, rootUtxo2, Alice.address, Alice.signer) assertSubmitFails(provider, deinitTx) } test("appendUnordered: valid append to empty list") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.init(utxos, ().toData, Alice.address, Alice.signer) provider.submit(initTx).await() val rootUtxo = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val appendTx = txCreator.appendUnordered( utxos2, rootUtxo, nodeKey("alpha"), ().toData, Alice.address, Alice.signer ) assert(provider.submit(appendTx).await().isRight, "appendUnordered should succeed") } test("appendUnordered: two nodes") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.init(utxos, ().toData, Alice.address, Alice.signer) provider.submit(initTx).await() // First append val rootUtxo1 = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val appendTx1 = txCreator.appendUnordered( utxos2, rootUtxo1, nodeKey("alpha"), ().toData, Alice.address, Alice.signer ) provider.submit(appendTx1).await() // Second append -- find the "alpha" node (it's the last element now) val alphaUtxo = Utxo(appendTx1.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && out.value.hasAsset( txCreator.policyId, AssetName(appendByteString(prefixBytes, nodeKey("alpha"))) ) }.get) val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val appendTx2 = txCreator.appendUnordered( utxos3, alphaUtxo, nodeKey("beta"), ().toData, Alice.address, Alice.signer ) assert(provider.submit(appendTx2).await().isRight, "second appendUnordered should succeed") } test("FAIL: appendUnordered when anchor has a link (not last element)") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.init(utxos, ().toData, Alice.address, Alice.signer) provider.submit(initTx).await() // Append two nodes so root -> alpha -> beta val rootUtxo1 = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val appendTx1 = txCreator.appendUnordered( utxos2, rootUtxo1, nodeKey("alpha"), ().toData, Alice.address, Alice.signer ) provider.submit(appendTx1).await() val alphaUtxo = Utxo(appendTx1.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && out.value.hasAsset( txCreator.policyId, AssetName(appendByteString(prefixBytes, nodeKey("alpha"))) ) }.get) val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val appendTx2 = txCreator.appendUnordered( utxos3, alphaUtxo, nodeKey("beta"), ().toData, Alice.address, Alice.signer ) provider.submit(appendTx2).await() // Now try to append to root (which already has link = alpha) -- must fail. // Root was re-produced by appendTx1 (appendTx2 only touched alpha). // Use constMaxBudget so the invalid tx can be built without running scripts. val (_, badTxCreator) = createSetup(EvaluatorMode.Validate) val rootUtxo2 = Utxo(appendTx1.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && out.value.hasAsset(txCreator.policyId, AssetName(rootKeyBytes)) }.get) val utxos4 = provider.findUtxos(Alice.address).await().toOption.get val appendTx3 = badTxCreator.appendUnordered( utxos4, rootUtxo2, nodeKey("gamma"), ().toData, Alice.address, Alice.signer ) assertSubmitFails(provider, appendTx3) } test("prependUnordered: valid prepend to non-empty list") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.init(utxos, ().toData, Alice.address, Alice.signer) provider.submit(initTx).await() // First add a node via append val rootUtxo1 = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val appendTx = txCreator.appendUnordered( utxos2, rootUtxo1, nodeKey("beta"), ().toData, Alice.address, Alice.signer ) provider.submit(appendTx).await() // Now prepend a node -- root -> alpha -> beta val rootUtxo2 = Utxo(appendTx.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && out.value.hasAsset(txCreator.policyId, AssetName(rootKeyBytes)) }.get) val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val prependTx = txCreator.prependUnordered( utxos3, rootUtxo2, nodeKey("alpha"), ().toData, Alice.address, Alice.signer ) assert(provider.submit(prependTx).await().isRight, "prependUnordered should succeed") } test("FAIL: prependUnordered when anchor is not the root") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.init(utxos, ().toData, Alice.address, Alice.signer) provider.submit(initTx).await() // Append two nodes: root -> alpha -> beta val rootUtxo1 = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val appendTx1 = txCreator.appendUnordered( utxos2, rootUtxo1, nodeKey("alpha"), ().toData, Alice.address, Alice.signer ) provider.submit(appendTx1).await() val alphaUtxo = Utxo(appendTx1.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && out.value.hasAsset( txCreator.policyId, AssetName(appendByteString(prefixBytes, nodeKey("alpha"))) ) }.get) val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val appendTx2 = txCreator.appendUnordered( utxos3, alphaUtxo, nodeKey("beta"), ().toData, Alice.address, Alice.signer ) provider.submit(appendTx2).await() // Try to prependUnordered using alpha as the anchor (not the root) -- must fail. val (_, badTxCreator) = createSetup(EvaluatorMode.Validate) val alphaUtxo2 = Utxo(appendTx2.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && out.value.hasAsset( txCreator.policyId, AssetName(appendByteString(prefixBytes, nodeKey("alpha"))) ) }.get) val utxos4 = provider.findUtxos(Alice.address).await().toOption.get val prependTx = badTxCreator.prependUnordered( utxos4, alphaUtxo2, nodeKey("gamma"), ().toData, Alice.address, Alice.signer ) assertSubmitFails(provider, prependTx) } test("insert: valid insert after root") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.init(utxos, ().toData, Alice.address, Alice.signer) provider.submit(initTx).await() val rootUtxo = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val insertTx = txCreator.insert( utxos2, rootUtxo, nodeKey("b"), ().toData, Alice.address, Alice.signer ) assert(provider.submit(insertTx).await().isRight, "insert should succeed") } test("insert: insert between two nodes") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.init(utxos, ().toData, Alice.address, Alice.signer) provider.submit(initTx).await() // root -> "c" val rootUtxo1 = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val insertTx1 = txCreator.insert( utxos2, rootUtxo1, nodeKey("c"), ().toData, Alice.address, Alice.signer ) provider.submit(insertTx1).await() // root -> "b" -> "c" (insert "b" after root) val rootUtxo2 = Utxo(insertTx1.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && out.value.hasAsset(txCreator.policyId, AssetName(rootKeyBytes)) }.get) val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val insertTx2 = txCreator.insert( utxos3, rootUtxo2, nodeKey("b"), ().toData, Alice.address, Alice.signer ) assert( provider.submit(insertTx2).await().isRight, "insert middle insert should succeed" ) } test("FAIL: insert with key greater than successor (wrong order)") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.init(utxos, ().toData, Alice.address, Alice.signer) provider.submit(initTx).await() // root -> "b" val rootUtxo1 = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val insertTx1 = txCreator.insert( utxos2, rootUtxo1, nodeKey("b"), ().toData, Alice.address, Alice.signer ) provider.submit(insertTx1).await() // Try to insert "c" after root -- but root points to "b", and "c" > "b" so order check fails. // Use constMaxBudget so the invalid tx can be built without running scripts. val (_, badTxCreator) = createSetup(EvaluatorMode.Validate) val rootUtxo2 = Utxo(insertTx1.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && out.value.hasAsset(txCreator.policyId, AssetName(rootKeyBytes)) }.get) val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val insertTx2 = badTxCreator.insert( utxos3, rootUtxo2, nodeKey("c"), ().toData, Alice.address, Alice.signer ) assertSubmitFails(provider, insertTx2) } test("remove: valid node removal") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.init(utxos, ().toData, Alice.address, Alice.signer) provider.submit(initTx).await() // Append one node: root -> alpha val rootUtxo1 = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val appendTx = txCreator.appendUnordered( utxos2, rootUtxo1, nodeKey("alpha"), ().toData, Alice.address, Alice.signer ) provider.submit(appendTx).await() val rootUtxo2 = Utxo(appendTx.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && out.value.hasAsset(txCreator.policyId, AssetName(rootKeyBytes)) }.get) val alphaUtxo = Utxo(appendTx.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && out.value.hasAsset( txCreator.policyId, AssetName(appendByteString(prefixBytes, nodeKey("alpha"))) ) }.get) val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val removeTx = txCreator.remove( utxos3, rootUtxo2, alphaUtxo, Alice.address, Alice.signer ) assert(provider.submit(removeTx).await().isRight, "remove should succeed") } test("removeHead: valid removal of head node, root datum updated") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.init(utxos, BigInt(0).toData, Alice.address, Alice.signer) provider.submit(initTx).await() // Append two nodes: root -> alpha -> beta val rootUtxo1 = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val appendTx1 = txCreator.appendUnordered( utxos2, rootUtxo1, nodeKey("alpha"), BigInt(1).toData, Alice.address, Alice.signer ) provider.submit(appendTx1).await() val alphaUtxo = Utxo(appendTx1.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && out.value.hasAsset( txCreator.policyId, AssetName(appendByteString(prefixBytes, nodeKey("alpha"))) ) }.get) val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val appendTx2 = txCreator.appendUnordered( utxos3, alphaUtxo, nodeKey("beta"), BigInt(2).toData, Alice.address, Alice.signer ) provider.submit(appendTx2).await() // removeHead: consume alpha, update root accumulator to 1 val scriptUtxos = provider.findUtxos(txCreator.scriptAddress).await().toOption.get.map(Utxo.apply).toSeq val rootUtxo2 = txCreator.findRoot(scriptUtxos) val headUtxo = txCreator.findNode(scriptUtxos, nodeKey("alpha")) val utxos4 = provider.findUtxos(Alice.address).await().toOption.get val removeHeadTx = txCreator.removeHead( utxos4, rootUtxo2, headUtxo, BigInt(1).toData, Alice.address, Alice.signer ) assert(provider.submit(removeHeadTx).await().isRight, "removeHead should succeed") // List should now be root -> beta val scriptUtxos2 = provider.findUtxos(txCreator.scriptAddress).await().toOption.get.map(Utxo.apply).toSeq val result = txCreator.readAll(scriptUtxos2) assert(result.size == 2, s"expected 2 elements, got ${result.size}") assert(result(1)._1 == nodeKey("beta"), s"expected beta as head, got ${result(1)._1}") } test("membership set: insert fills the gap between two bracketing nodes") { // Build root -> "a" -> "b", then prove "ab" belongs between them. // "a" < "ab" < "b" holds byte-wise: "ab" starts with "a" so it's greater, // and it has no "b" suffix so it's less than "b". val (provider, txCreator) = createSetup() val scriptUtxos = () => provider.findUtxos(txCreator.scriptAddress).await().toOption.get.map(Utxo.apply).toSeq val utxos = provider.findUtxos(Alice.address).await().toOption.get provider.submit(txCreator.init(utxos, ().toData, Alice.address, Alice.signer)).await() val utxos2 = provider.findUtxos(Alice.address).await().toOption.get provider .submit( txCreator.insert( utxos2, txCreator.findRoot(scriptUtxos()), nodeKey("a"), ().toData, Alice.address, Alice.signer ) ) .await() val utxos3 = provider.findUtxos(Alice.address).await().toOption.get provider .submit( txCreator.insert( utxos3, txCreator.findNode(scriptUtxos(), nodeKey("a")), nodeKey("b"), ().toData, Alice.address, Alice.signer ) ) .await() // root -> "a" -> "b": prove "ab" is absent by inserting it using only "a" as anchor. // The validator accepts this iff "a" < "ab" < "b" -- the gap exists and "ab" was not there. val utxos4 = provider.findUtxos(Alice.address).await().toOption.get val insertTx = txCreator.insert( utxos4, txCreator.findNode(scriptUtxos(), nodeKey("a")), nodeKey("ab"), ().toData, Alice.address, Alice.signer ) assert(provider.submit(insertTx).await().isRight, "inserting into the gap should succeed") assert( txCreator.readAll(scriptUtxos()).map(_._1) == Seq( rootKeyBytes, nodeKey("a"), nodeKey("ab"), nodeKey("b") ) ) } test("membership set: FAIL duplicate key is rejected") { // root -> "b": attempting to insert "b" again must fail. val (provider, txCreator) = createSetup() val scriptUtxos = () => provider.findUtxos(txCreator.scriptAddress).await().toOption.get.map(Utxo.apply).toSeq val utxos = provider.findUtxos(Alice.address).await().toOption.get provider.submit(txCreator.init(utxos, ().toData, Alice.address, Alice.signer)).await() val utxos2 = provider.findUtxos(Alice.address).await().toOption.get provider .submit( txCreator.insert( utxos2, txCreator.findRoot(scriptUtxos()), nodeKey("b"), ().toData, Alice.address, Alice.signer ) ) .await() // Try to insert "b" again using root as anchor -- "b" < "b" is false, so it fails. val (_, badTxCreator) = createSetup(EvaluatorMode.Validate) val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val dupTx = badTxCreator.insert( utxos3, txCreator.findRoot(scriptUtxos()), nodeKey("b"), ().toData, Alice.address, Alice.signer ) assertSubmitFails(provider, dupTx) } test("property: insert -- readAll keys are always in strict ascending order") { forAll(LinkedListTest.distinctNodeKeys) { keys => val (provider, txCreator) = createSetup() val walletUtxos = provider.findUtxos(Alice.address).await().toOption.get provider .submit(txCreator.init(walletUtxos, ().toData, Alice.address, Alice.signer)) .await() for key <- keys do val scriptUtxos = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val anchor = txCreator.findAnchorFor(scriptUtxos, key) val walletUtxos = provider.findUtxos(Alice.address).await().toOption.get provider .submit( txCreator .insert(walletUtxos, anchor, key, ().toData, Alice.address, Alice.signer) ) .await() val scriptUtxos = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val resultKeys = txCreator.readAll(scriptUtxos).map(_._1).tail // drop root val isSorted = resultKeys .zip(resultKeys.tail) .forall((a, b) => summon[Ordering[ByteString]].compare(a, b) < 0) assert(isSorted, s"Keys not in strict ascending order: $resultKeys") } } test("property: insert -- length equals number of inserted keys") { forAll(LinkedListTest.distinctNodeKeys) { keys => val (provider, txCreator) = createSetup() val walletUtxos = provider.findUtxos(Alice.address).await().toOption.get provider .submit(txCreator.init(walletUtxos, ().toData, Alice.address, Alice.signer)) .await() for key <- keys do val scriptUtxos = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val anchor = txCreator.findAnchorFor(scriptUtxos, key) val walletUtxos = provider.findUtxos(Alice.address).await().toOption.get provider .submit( txCreator .insert(walletUtxos, anchor, key, ().toData, Alice.address, Alice.signer) ) .await() val scriptUtxos = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val result = txCreator.readAll(scriptUtxos) // root + one entry per key assert( result.size == keys.size + 1, s"expected ${keys.size + 1} elements, got ${result.size}" ) } } test("property: duplicate key is always rejected") { forAll(LinkedListTest.distinctNodeKeys) { keys => whenever(keys.nonEmpty) { val (provider, txCreator) = createSetup() val (_, badTxCreator) = createSetup(EvaluatorMode.Validate) val walletUtxos = provider.findUtxos(Alice.address).await().toOption.get provider .submit(txCreator.init(walletUtxos, ().toData, Alice.address, Alice.signer)) .await() for key <- keys do val scriptUtxos = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val anchor = txCreator.findAnchorFor(scriptUtxos, key) val walletUtxos = provider.findUtxos(Alice.address).await().toOption.get provider .submit( txCreator.insert( walletUtxos, anchor, key, ().toData, Alice.address, Alice.signer ) ) .await() // Pick any key already in the list and try to insert it again val dupKey = keys.head val scriptUtxos = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val anchor = txCreator.findAnchorFor(scriptUtxos, dupKey) val walletUtxos2 = provider.findUtxos(Alice.address).await().toOption.get val dupTx = badTxCreator.insert( walletUtxos2, anchor, dupKey, ().toData, Alice.address, Alice.signer ) assertSubmitFails(provider, dupTx) } } } test("property: remove -- order preserved and length decremented") { forAll(LinkedListTest.distinctNodeKeys) { keys => whenever(keys.nonEmpty) { val (provider, txCreator) = createSetup() val walletUtxos = provider.findUtxos(Alice.address).await().toOption.get provider .submit(txCreator.init(walletUtxos, ().toData, Alice.address, Alice.signer)) .await() for key <- keys do val scriptUtxos = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val anchor = txCreator.findAnchorFor(scriptUtxos, key) val walletUtxos = provider.findUtxos(Alice.address).await().toOption.get provider .submit( txCreator.insert( walletUtxos, anchor, key, ().toData, Alice.address, Alice.signer ) ) .await() // Remove the first node in list order (head of the sorted list) val scriptUtxos = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val rootUtxo = txCreator.findRoot(scriptUtxos) val headKey = txCreator.readElement(rootUtxo).link.get // root always has a link val headUtxo = txCreator.findNode(scriptUtxos, headKey) val walletUtxos2 = provider.findUtxos(Alice.address).await().toOption.get provider .submit( txCreator .remove(walletUtxos2, rootUtxo, headUtxo, Alice.address, Alice.signer) ) .await() val scriptUtxos2 = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val resultKeys = txCreator.readAll(scriptUtxos2).map(_._1).tail // drop root assert( resultKeys.size == keys.size - 1, s"expected ${keys.size - 1} nodes after remove, got ${resultKeys.size}" ) val isSorted = resultKeys .zip(resultKeys.tail) .forall((a, b) => summon[Ordering[ByteString]].compare(a, b) < 0) assert(isSorted, s"Keys not in strict ascending order after remove: $resultKeys") } } } test("property: insert then remove is identity") { forAll(LinkedListTest.distinctNodeKeys) { keys => whenever(keys.size >= 2) { val (provider, txCreator) = createSetup() val walletUtxos = provider.findUtxos(Alice.address).await().toOption.get provider .submit(txCreator.init(walletUtxos, ().toData, Alice.address, Alice.signer)) .await() // Insert all but the last key val existing = keys.init val newKey = keys.last for key <- existing do val scriptUtxos = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val anchor = txCreator.findAnchorFor(scriptUtxos, key) val walletUtxos = provider.findUtxos(Alice.address).await().toOption.get provider .submit( txCreator.insert( walletUtxos, anchor, key, ().toData, Alice.address, Alice.signer ) ) .await() // Snapshot keys before insert val scriptUtxosBefore = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val keysBefore = txCreator.readAll(scriptUtxosBefore).map(_._1) // Insert newKey then remove it val anchor = txCreator.findAnchorFor(scriptUtxosBefore, newKey) val walletUtxos2 = provider.findUtxos(Alice.address).await().toOption.get val insertTx = txCreator.insert( walletUtxos2, anchor, newKey, ().toData, Alice.address, Alice.signer ) provider.submit(insertTx).await() val scriptUtxosAfterInsert = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val anchorAfter = txCreator.findAnchorFor(scriptUtxosAfterInsert, newKey) val newNodeUtxo = txCreator.findNode(scriptUtxosAfterInsert, newKey) val walletUtxos3 = provider.findUtxos(Alice.address).await().toOption.get provider .submit( txCreator.remove( walletUtxos3, anchorAfter, newNodeUtxo, Alice.address, Alice.signer ) ) .await() // Keys after insert+remove must equal keys before val scriptUtxosAfter = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val keysAfter = txCreator.readAll(scriptUtxosAfter).map(_._1) assert( keysAfter == keysBefore, s"Keys changed after insert+remove: before=$keysBefore after=$keysAfter" ) } } } test("property: deinit succeeds iff list is empty") { forAll(LinkedListTest.distinctNodeKeys) { keys => whenever(keys.nonEmpty) { val (provider, txCreator) = createSetup() val (_, badTxCreator) = createSetup(EvaluatorMode.Validate) val walletUtxos = provider.findUtxos(Alice.address).await().toOption.get provider .submit(txCreator.init(walletUtxos, ().toData, Alice.address, Alice.signer)) .await() // Insert at least one node val key = keys.head val scriptUtxos = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val anchor = txCreator.findAnchorFor(scriptUtxos, key) val walletUtxos2 = provider.findUtxos(Alice.address).await().toOption.get provider .submit( txCreator .insert(walletUtxos2, anchor, key, ().toData, Alice.address, Alice.signer) ) .await() // Deinit must fail while list is non-empty val scriptUtxos2 = provider .findUtxos(txCreator.scriptAddress) .await() .toOption .get .map(Utxo.apply) .toSeq val rootUtxo = txCreator.findRoot(scriptUtxos2) val walletUtxos3 = provider.findUtxos(Alice.address).await().toOption.get val deinitTx = badTxCreator.deinit(walletUtxos3, rootUtxo, Alice.address, Alice.signer) assertSubmitFails(provider, deinitTx) } } } test("readAll: reconstructs list in order") { val (provider, txCreator) = createSetup() val utxos = provider.findUtxos(Alice.address).await().toOption.get val rootData = BigInt(0).toData val alphaData = BigInt(1).toData val betaData = BigInt(2).toData val initTx = txCreator.init(utxos, rootData, Alice.address, Alice.signer) provider.submit(initTx).await() val rootUtxo1 = Utxo(initTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val appendTx1 = txCreator.appendUnordered( utxos2, rootUtxo1, nodeKey("alpha"), alphaData, Alice.address, Alice.signer ) provider.submit(appendTx1).await() val alphaUtxo = Utxo(appendTx1.utxos.find { case (_, out) => out.address == txCreator.scriptAddress && !out.value.hasAsset(txCreator.policyId, AssetName(rootKeyBytes)) }.get) val utxos3 = provider.findUtxos(Alice.address).await().toOption.get val appendTx2 = txCreator.appendUnordered( utxos3, alphaUtxo, nodeKey("beta"), betaData, Alice.address, Alice.signer ) provider.submit(appendTx2).await() val scriptUtxos = provider.findUtxos(txCreator.scriptAddress).await().toOption.get val result = txCreator.readAll(scriptUtxos.map(Utxo.apply).toSeq) assert(result.size == 3, s"expected 3 elements, got ${result.size}") assert(result(0) == (rootKeyBytes, rootData), s"root mismatch: ${result(0)}") assert(result(1) == (nodeKey("alpha"), alphaData), s"alpha mismatch: ${result(1)}") assert(result(2) == (nodeKey("beta"), betaData), s"beta mismatch: ${result(2)}") } } object LinkedListTest extends ScalusTest { given env: CardanoInfo = scalus.testing.kit.TestUtil.testEnvironment val rootKeyBytes: ByteString = ByteString.fromString("HEAD") val prefixBytes: ByteString = ByteString.fromString("N:") private val compiledContract = LinkedListContract.compiled.withErrorTraces def nodeKey(label: String): ByteString = ByteString.fromString(label) /** Generates a list of 1–6 distinct node keys, each at most 30 bytes (prefix is 2 bytes, * Cardano asset names are capped at 32 bytes total). */ val distinctNodeKeys: Gen[List[ByteString]] = Gen .listOfN( 6, Gen.chooseNum(1, 30) .flatMap(n => Gen.listOfN(n, Gen.choose(0x20.toByte, 0x7e.toByte))) ) .map(_.map(bytes => ByteString.fromArray(bytes.toArray)).distinct) .suchThat(_.nonEmpty) /** Create a fresh (provider, txCreator) pair for one test. * * @param evaluatorMode * `EvaluateAndComputeCost` (default) for valid transactions; `Validate` with * `constMaxBudget` when you need to build an intentionally invalid tx without the TxBuilder * running scripts. */ def createSetup( evaluatorMode: EvaluatorMode = EvaluatorMode.EvaluateAndComputeCost ): (Emulator, LinkedListOffchain) = { val evaluator = evaluatorMode match case EvaluatorMode.EvaluateAndComputeCost => PlutusScriptEvaluator(env, EvaluatorMode.EvaluateAndComputeCost) case _ => PlutusScriptEvaluator.constMaxBudget(env) val provider = Emulator( initialUtxos = Map( Input(genesisHash, 0) -> Output(Alice.address, Value.ada(10_000)), Input(genesisHash, 1) -> Output(Alice.address, Value.ada(10_000)), Input(genesisHash, 2) -> Output(Alice.address, Value.ada(10_000)), Input(genesisHash, 3) -> Output(Bob.address, Value.ada(10_000)) ), initialContext = Context.testMainnet().copy(evaluatorMode = EvaluatorMode.EvaluateAndComputeCost), mutators = Set(PlutusScriptsTransactionMutator) ) val txCreator = LinkedListOffchain( env = env, evaluator = evaluator, mintingContract = compiledContract, rootKey = rootKeyBytes, prefix = prefixBytes ) (provider, txCreator) } def assertSubmitFails(provider: Emulator, tx: Transaction): Unit = provider.submit(tx).await() match { case Left(_) => () case Right(_) => fail("Expected transaction submission to fail but it succeeded") } } ``` # Example: lottery ## scalus-examples/jvm/src/main/scala/scalus/examples/lottery/LotteryContract.scala ```scala package scalus.examples.lottery import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 object LotteryContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(LotteryValidator.validate) lazy val blueprint = Blueprint.plutusV3[State, Action]( title = "Two-player lottery contract", description = "Two-player commit-reveal betting contract where winner is determined by combined preimage length modulo 2.", version = "1.0.0", compiled = compiled, license = None ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/lottery/LotteryTransactions.scala ```scala package scalus.examples.lottery import scalus.uplc.builtin.Data import scalus.cardano.address.Address import scalus.cardano.ledger.* import scalus.cardano.txbuilder.* import scalus.cardano.onchain.plutus.v1.PosixTime import scalus.uplc.PlutusV3 import java.time.Instant case class LotteryTransactions( env: CardanoInfo, evaluator: PlutusScriptEvaluator, contract: PlutusV3[Data => Unit] ) { val scriptAddress: Address = contract.address(env.network) /** Create initial lottery UTXO. Both players contribute equal bet amounts and both must sign. */ def initiateLottery( playerOneUtxos: Utxos, playerTwoUtxos: Utxos, betAmount: Coin, playerOnePkh: AddrKeyHash, playerTwoPkh: AddrKeyHash, secret1: Secret, secret2: Secret, revealDeadline: PosixTime, changeAddress: Address, playerOneSigner: TransactionSigner, playerTwoSigner: TransactionSigner ): Transaction = { val datum = State( playerOneSecret = secret1, playerTwoSecret = secret2, revealDeadline = revealDeadline, lotteryState = LotteryState.Empty ) val playerOneUtxo = Utxo(playerOneUtxos.head) val playerTwoUtxo = Utxo(playerTwoUtxos.head) val allUtxos = playerOneUtxos ++ playerTwoUtxos TxBuilder(env) .spend(playerOneUtxo) .spend(playerTwoUtxo) .payTo(scriptAddress, Value.lovelace(betAmount.value * 2), datum) .complete(availableUtxos = allUtxos, sponsor = changeAddress) .sign(playerOneSigner) .sign(playerTwoSigner) .transaction } def revealPlayerOne( utxos: Utxos, lotteryUtxo: Utxo, preimage: Preimage, playerOnePkh: AddrKeyHash, playerOneSecret: Secret, playerTwoSecret: Secret, revealDeadline: PosixTime, sponsor: Address, validTo: Instant, signer: TransactionSigner ): Transaction = { val redeemer = Action.RevealPlayerOne(preimage) // Construct new state with PlayerOneRevealed val newLotteryState = LotteryState.PlayerOneRevealed( BigInt(preimage.bytes.length), scalus.cardano.onchain.plutus.v1.PubKeyHash(playerOnePkh) ) val newState = State( playerOneSecret = playerOneSecret, playerTwoSecret = playerTwoSecret, revealDeadline = revealDeadline, lotteryState = newLotteryState ) TxBuilder(env, evaluator) .spend(lotteryUtxo, redeemer, contract) .requireSignature(playerOnePkh) .payTo(scriptAddress, lotteryUtxo.output.value, newState) .validTo(validTo) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } def revealPlayerTwo( utxos: Utxos, lotteryUtxo: Utxo, preimage: Preimage, playerTwoPkh: AddrKeyHash, playerOneSecret: Secret, playerTwoSecret: Secret, revealDeadline: PosixTime, sponsor: Address, validTo: Instant, signer: TransactionSigner ): Transaction = { val redeemer = Action.RevealPlayerTwo(preimage) val newLotteryState = LotteryState.PlayerTwoRevealed( BigInt(preimage.bytes.length), scalus.cardano.onchain.plutus.v1.PubKeyHash(playerTwoPkh) ) val newState = State( playerOneSecret = playerOneSecret, playerTwoSecret = playerTwoSecret, revealDeadline = revealDeadline, lotteryState = newLotteryState ) TxBuilder(env, evaluator) .spend(lotteryUtxo, redeemer, contract) .requireSignature(playerTwoPkh) .payTo(scriptAddress, lotteryUtxo.output.value, newState) .validTo(validTo) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Non-revealing player claims pot after deadline when opponent failed to reveal in time. */ def timeout( utxos: Utxos, lotteryUtxo: Utxo, preimage: Preimage, claimantPkh: AddrKeyHash, payeeAddress: Address, sponsor: Address, validFrom: Instant, signer: TransactionSigner ): Transaction = { val redeemer = Action.Timeout(preimage) TxBuilder(env, evaluator) .spend(lotteryUtxo, redeemer, contract) .requireSignature(claimantPkh) .payTo(payeeAddress, lotteryUtxo.output.value) .validFrom(validFrom) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Losing player concedes by revealing their preimage and directing pot to winner. */ def lose( utxos: Utxos, lotteryUtxo: Utxo, preimage: Preimage, loserPkh: AddrKeyHash, winnerAddress: Address, winnerOutputIdx: BigInt, sponsor: Address, validTo: Instant, signer: TransactionSigner ): Transaction = { val redeemer = Action.Lose(preimage, winnerOutputIdx) TxBuilder(env, evaluator) .spend(lotteryUtxo, redeemer, contract) .requireSignature(loserPkh) .payTo(winnerAddress, lotteryUtxo.output.value) .validTo(validTo) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/lottery/LotteryValidator.scala ```scala package scalus.examples.lottery import scalus.uplc.builtin.Builtins.sha2_256 import scalus.uplc.builtin.{ByteString, Data, FromData, ToData} import scalus.cardano.onchain.plutus.v1.{PosixTime, PubKeyHash} import scalus.cardano.onchain.plutus.v3.{TxInfo, TxOutRef, Validator} import scalus.cardano.onchain.plutus.{v1, v2} import scalus.cardano.onchain.plutus.prelude.* import scalus.compiler.Compile type Preimage = ByteString type Secret = ByteString // Datum case class State( playerOneSecret: Secret, playerTwoSecret: Secret, revealDeadline: PosixTime, lotteryState: LotteryState, ) derives FromData, ToData enum LotteryState derives FromData, ToData: case Empty case PlayerOneRevealed(length: BigInt, pubKeyHash: PubKeyHash) case PlayerTwoRevealed(length: BigInt, pubKeyHash: PubKeyHash) // Redeemer enum Action derives ToData, FromData: case RevealPlayerOne(preimage: Preimage) case RevealPlayerTwo(preimage: Preimage) case Lose(preimage: Preimage, winnerOutputIdx: BigInt) case Timeout(preimage: Preimage) /** A lottery between two players, where each of them commits a bet and the winner takes both bets. * Since Cardano is deterministic, this lottery uses a commit-reveal-punish scheme to ensure * fairness. * * The scheme works by two players commiting a secret beforehand, and using the preimages of the * secrets to determine a winner using a fair function, in this case, a `mod(2)` of the pre-images * lengths. * * The lottery starts off with a multisig transaction that commits both players bets to the * contract. Then, the lottery is considered [[LotteryState.Empty]]. * * Then, any of their players can issue a Reveal action, supplying their preimage. * * After that, any action ends the lottery. The valid actions are: * * 1) The second player reveals their secret. The revealing player in this case already knows the * outcome of the lottery, and by doing their reveal, claims a victory. The player supplies the * preimage so that the contract can confirm their victory, and gets their payout. If the supplied * preimage does not hash to the initially commited secret, the validator fails, allowing the first * player to claim the pot via a [[Action.Timeout]] after a delay. * * 2) The second player concedes the pot by sending an [[Action.Lose]]. As explained above, the * first players reveal has given the second player information to determine their loss. * * 3) If the second player has failed to reveal the secret, the first player can claim the pot via * a [[Action.Timeout]] after a specified delay. * * @note * It's *recommended* that the players use preimages that are at least 32 bytes long to ensure * security of their secrets. Otherwise, a malicious player could guess their opponents preimage * by using a brute force attack against their secret. */ @Compile object LotteryValidator extends Validator { inline def spend( datum: scalus.cardano.onchain.plutus.prelude.Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val ownInput = tx.findOwnInputOrFail(ownRef) val amount = ownInput.resolved.value.getLovelace val action = redeemer.to[Action] val state = datum.getOrFail("Datum not found").to[State] state.lotteryState match { // If the lottery state is empty, i.e. no players have revealed yet, the only possible thing is the revelation // by one of the players. case LotteryState.Empty => action match { case Action.RevealPlayerOne(preimage) => // verify player identity val isValid = sha2_256(preimage) === state.playerOneSecret require(isValid, "Fraudulent attempt") val continuationOutputs = tx.outputs.filter(out => out.address === ownInput.resolved.address) require( continuationOutputs.length == BigInt(1), "Must have exactly one continuation output" ) val continuationOutput = continuationOutputs.head val newState = continuationOutput.datum match { case v2.OutputDatum.OutputDatum(datum) => datum.to[State] case _ => fail("continuation out must have an inline datum") } // Verify state transition is valid newState.lotteryState match { case LotteryState.PlayerOneRevealed(length, pkh) => require(length === preimage.length, "Length mismatch") require( tx.signatories.exists(_ === pkh), "Must be signed by player one" ) case _ => fail("Invalid state transition") } // Verify secrets and deadline are unchanged require( newState.playerOneSecret === state.playerOneSecret, "Player one secret must not change" ) require( newState.playerTwoSecret === state.playerTwoSecret, "Player two secret must not change" ) require( newState.revealDeadline === state.revealDeadline, "Reveal deadline must not change" ) case Action.RevealPlayerTwo(preimage) => // Verify preimage hash matches val isValid = sha2_256(preimage) === state.playerTwoSecret require(isValid, "Fraudulent attempt") // Find the continuation output with updated state val continuationOutputs = tx.outputs.filter(out => out.address === ownInput.resolved.address) require( continuationOutputs.length === BigInt(1), "Must have exactly one continuation output" ) val continuationOutput = continuationOutputs.head val newState = continuationOutput.datum match { case v2.OutputDatum.OutputDatum(datum) => datum.to[State] case _ => fail("continuation out must have an inline datum") } // Verify state transition is valid newState.lotteryState match { case LotteryState.PlayerTwoRevealed(length, pkh) => require(length === preimage.length, "Length mismatch") require( tx.signatories.exists(_ === pkh), "Must be signed by player two" ) case _ => fail("Invalid state transition") } // Verify secrets and deadline are unchanged require( newState.playerOneSecret === state.playerOneSecret, "Player one secret must not change" ) require( newState.playerTwoSecret === state.playerTwoSecret, "Player two secret must not change" ) require( newState.revealDeadline === state.revealDeadline, "Reveal deadline must not change" ) case _ => fail("Too early to give up or claim a timeout -- need to reveal first") } // If the first player has revealed, one of the three things is possible: // 1) Player two reveals. In this case, since they already know the player 1 preimage they know that they // have won. Otherwise, they can claim a loss // 2) Player two claims a loss, since they can see that the other players secret wins. // 3) Player one claims the prize after a timeout if the player two has failed to reveal their secret. // // Any of this actions must be accompanied by the preimage to verify the player's identity. All other actions // are impossible. case LotteryState.PlayerOneRevealed(playerOnePreimageLen, playerOnePkh) => action match { case Action.RevealPlayerOne(_) => fail("Player one already revealed") case Action.RevealPlayerTwo(playerTwoPreimage) => val isReallyPlayerTwo = sha2_256(playerTwoPreimage) === state.playerTwoSecret require(isReallyPlayerTwo, "Fraudulent attempt") // A winning reveal must land before the deadline; otherwise it would race // the opponent's Timeout (which is only valid after the deadline). require( tx.validRange.isEntirelyBefore(state.revealDeadline), "Reveal too late" ) val totalLength = playerOnePreimageLen + playerTwoPreimage.length require(totalLength % 2 == BigInt(0), "Unlucky") case Action.Lose(playerTwoPreimage, winnerOutputIdx) => // Player two concedes, giving pot to player one val isReallyPlayerTwo = sha2_256(playerTwoPreimage) === state.playerTwoSecret require(isReallyPlayerTwo, "Fraudulent attempt") // Verify output to player one contains all the money val supposedWinnerOutput = tx.outputs.at(winnerOutputIdx) supposedWinnerOutput.address.credential match { case v1.Credential.PubKeyCredential(hash) => require(hash === playerOnePkh, "Wrong winner") case v1.Credential.ScriptCredential(_) => fail("Winner must be pubkey") } require( supposedWinnerOutput.value.getLovelace >= amount, "Insufficient payout" ) case Action.Timeout(playerOnePreimage) => // Player two didn't reveal in time, player one claims pot val isReallyPlayerOne = sha2_256(playerOnePreimage) === state.playerOneSecret require(isReallyPlayerOne, "Fraudulent attempt") // Verify time has passed deadline require( tx.validRange.isEntirelyAfter(state.revealDeadline), "Deadline not reached" ) // playerOnePreimage is already public (revealed when reaching this state), // so anyone can submit this Timeout. Pin the pot to the revealer (player // one) so a third party cannot redirect it to themselves. require( paysAtLeast(tx, playerOnePkh, amount), "Timeout must pay the revealer" ) } // This branch mirrors the one above, but for the player two. / case LotteryState.PlayerTwoRevealed(playerTwoPreimageLen, playerTwoPkh) => action match { case Action.RevealPlayerTwo(_) => fail("Player two already revealed") case Action.RevealPlayerOne(playerOnePreimage) => require( sha2_256(playerOnePreimage) === state.playerOneSecret, "Fraudulent attempt" ) // A winning reveal must land before the deadline; otherwise it would race // the opponent's Timeout (which is only valid after the deadline). require( tx.validRange.isEntirelyBefore(state.revealDeadline), "Reveal too late" ) val totalLength = playerTwoPreimageLen + playerOnePreimage.length require(totalLength % 2 == BigInt(0), "Unlucky") case Action.Lose(playerOnePreimage, winnerOutputIdx) => // Player one concedes, giving pot to player two require( sha2_256(playerOnePreimage) === state.playerOneSecret, "Fraudulent attempt" ) val supposedWinnerOutput = tx.outputs.at(winnerOutputIdx) supposedWinnerOutput.address.credential match { case v1.Credential.PubKeyCredential(hash) => require(hash === playerTwoPkh, "Wrong winner") case v1.Credential.ScriptCredential(_) => fail("Winner must be pubkey") } require( supposedWinnerOutput.value.getLovelace >= amount, "Insufficient payout" ) case Action.Timeout(playerTwoPreimage) => // Player one didn't reveal in time, player two claims pot require( sha2_256(playerTwoPreimage) === state.playerTwoSecret, "Fraudulent attempt" ) // Verify time has passed deadline require( tx.validRange.isEntirelyAfter(state.revealDeadline), "Deadline not reached" ) // playerTwoPreimage is already public (revealed when reaching this state), // so anyone can submit this Timeout. Pin the pot to the revealer (player // two) so a third party cannot redirect it to themselves. require( paysAtLeast(tx, playerTwoPkh, amount), "Timeout must pay the revealer" ) } } } /** True if some output pays at least `amount` lovelace to the public-key `pkh`. */ private inline def paysAtLeast(tx: TxInfo, pkh: PubKeyHash, amount: BigInt): Boolean = tx.outputs.exists { out => out.address.credential match case v1.Credential.PubKeyCredential(h) => h === pkh && out.value.getLovelace >= amount case _ => false } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/lottery/LotteryScalaCheckCommandTest.scala ```scala package scalus.examples.lottery import org.scalacheck.{Arbitrary, Gen, Prop} import org.scalatest.funsuite.AnyFunSuite import scalus.cardano.address.{Network, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart} import scalus.cardano.ledger.* import scalus.cardano.node.{BlockchainReader, Emulator} import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.cardano.txbuilder.{TransactionSigner, TxBuilder} import scalus.cardano.wallet.hd.{HdAccount, HdKeyPair} import scalus.crypto.ed25519.given import scalus.cardano.txbuilder.TxBuilderException import scalus.testing.* import scalus.uplc.builtin.Builtins.sha2_256 import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{ByteString, Data} import java.time.Instant import scala.concurrent.duration.Duration import scala.concurrent.{Await, ExecutionContext, Future} /** ScalaCheck Commands property-based test for lottery contract with many concurrent games. * * Creates 10 lottery games with overlapping HD-derived participants. Uses * ContractScalaCheckCommands to generate random sequences of actions (reveal, lose, timeout, * wait), verifying invariants hold after each successful transaction. */ class LotteryScalaCheckCommandTest extends AnyFunSuite { import LotteryScalaCheckCommandTest.* private given ExecutionContext = ExecutionContext.global test("lottery: invariants hold under random action sequences with many concurrent games") { val (emulator, gameInfos) = createEmulatorWithGames() val step = makeLotteryStep(gameInfos) val commands = ContractScalaCheckCommands(emulator, step) { (reader, state) => Future.successful { val props = state.games.values.map { gameOnChain => val datum = gameOnChain.datum val info = gameOnChain.info Prop(datum.playerOneSecret == info.secret1) :| "secret1 unchanged" && Prop(datum.playerTwoSecret == info.secret2) :| "secret2 unchanged" && Prop(datum.revealDeadline == info.revealDeadline) :| "deadline unchanged" && Prop( gameOnChain.utxo.output.value.coin.value >= info.betAmount ) :| "pot >= bet" } if props.isEmpty then Prop.passed else props.reduce(_ && _) } } val result = org.scalacheck.Test.check( org.scalacheck.Test.Parameters.default .withMinSuccessfulTests(15) .withMaxDiscardRatio(20), commands.property() ) assert(result.passed, s"Property test failed: $result") } } object LotteryScalaCheckCommandTest { private val compiledContract = LotteryContract.compiled.withErrorTraces private val lotteryScript = compiledContract.script private val network = Network.Mainnet private val scriptAddress = compiledContract.address(network) private val deadlineSlot: Long = 50L private val beforeDeadlineSlot: Long = deadlineSlot - 10 private val betAmount = 5_000_000L // ========================================================================= // Participants (10 players via HD derivation) // ========================================================================= case class Participant(index: Int, account: HdAccount) { lazy val addrKeyHash: AddrKeyHash = account.paymentKeyHash val address: ShelleyAddress = ShelleyAddress( network, ShelleyPaymentPart.Key(account.paymentKeyHash), ShelleyDelegationPart.Null ) lazy val signer: TransactionSigner = new TransactionSigner(Set(account.paymentKeyPair)) } private val mnemonic: String = "test test test test test test test test test test test test " + "test test test test test test test test test test test sauce" private val numParticipants = 10 private val participants: IndexedSeq[Participant] = { val masterKey = HdKeyPair.masterFromMnemonic(mnemonic, "") val purposeKey = masterKey.deriveHardened(1852) val coinTypeKey = purposeKey.deriveHardened(1815) (0 until numParticipants).map { i => val accountKey = coinTypeKey.deriveHardened(i) Participant(i, new HdAccount(i, accountKey)) } } // ========================================================================= // Game definitions — 10 games with overlapping players // ========================================================================= case class GameInfo( secret1: Secret, secret2: Secret, preimage1: Preimage, preimage2: Preimage, player1: Participant, player2: Participant, betAmount: Long, revealDeadline: Long // slot-time millis ) case class GameOnChain( utxo: Utxo, datum: State, info: GameInfo ) case class LotteryTestState( games: Map[(Secret, Secret), GameOnChain] ) private def genByteStringOfN(n: Int): Gen[ByteString] = Gen.containerOfN[Array, Byte](n, Arbitrary.arbitrary[Byte]) .map(a => ByteString.unsafeFromArray(a)) // Pairing: player[i] vs player[(i*3+1) % N] gives varied overlap private val gameDefs: IndexedSeq[(Int, Int, Int, Int)] = (0 until 10).map { i => val p1Idx = i % numParticipants val p2Idx = (i * 3 + 1) % numParticipants val p2Fixed = if p2Idx == p1Idx then (p1Idx + 1) % numParticipants else p2Idx val len1 = 32 val len2 = if i % 3 == 0 then 16 else if i % 3 == 1 then 17 else 20 (p1Idx, p2Fixed, len1, len2) } private val gamePreimages: IndexedSeq[(Preimage, Preimage, Secret, Secret)] = gameDefs.map { case (_, _, len1, len2) => val p1 = genByteStringOfN(len1).sample.get val p2 = genByteStringOfN(len2).sample.get (p1, p2, sha2_256(p1), sha2_256(p2)) } // ========================================================================= // Helpers // ========================================================================= private def findAllGameUtxos( reader: BlockchainReader )(using ExecutionContext): Future[Map[(Secret, Secret), Utxo]] = reader.findUtxos(scriptAddress).map(_.getOrElse(Map.empty)).map { utxos => utxos.flatMap { (input, output) => output.inlineDatum.flatMap { d => scala.util.Try { val state = d.to[State] (state.playerOneSecret, state.playerTwoSecret) -> Utxo(input, output) }.toOption } } } // ========================================================================= // Actors // ========================================================================= /** Actor that reveals player 1's preimage in a game where no one has revealed yet. */ class RevealP1Actor(info: GameInfo) extends ContractTestActor[LotteryTestState] { override def name: String = s"reveal-p1-${info.player1.index}" override def actions(reader: BlockchainReader, state: LotteryTestState)(using ExecutionContext ): Future[Seq[StepAction]] = reader.currentSlot.flatMap { currentSlot => val slotTime = reader.cardanoInfo.slotConfig.slotToTime(currentSlot) val secrets = (info.secret1, info.secret2) state.games.get(secrets) match case Some(game) if game.datum.lotteryState == LotteryState.Empty && slotTime < info.revealDeadline => buildRevealP1Tx(reader, game.utxo, info) .map(tx => Seq(StepAction.Submit(tx))) .recover { case _: TxBuilderException => Seq.empty } case _ => Future.successful(Seq.empty) } } /** Actor that reveals player 2's preimage in a game where no one has revealed yet. */ class RevealP2Actor(info: GameInfo) extends ContractTestActor[LotteryTestState] { override def name: String = s"reveal-p2-${info.player2.index}" override def actions(reader: BlockchainReader, state: LotteryTestState)(using ExecutionContext ): Future[Seq[StepAction]] = reader.currentSlot.flatMap { currentSlot => val slotTime = reader.cardanoInfo.slotConfig.slotToTime(currentSlot) val secrets = (info.secret1, info.secret2) state.games.get(secrets) match case Some(game) if game.datum.lotteryState == LotteryState.Empty && slotTime < info.revealDeadline => buildRevealP2Tx(reader, game.utxo, info) .map(tx => Seq(StepAction.Submit(tx))) .recover { case _: TxBuilderException => Seq.empty } case _ => Future.successful(Seq.empty) } } /** Actor that reveals the second player after one has already revealed, or claims a lose. */ class SecondRevealOrLoseActor( info: GameInfo, player: Participant, preimage: Preimage, opponentAddr: ShelleyAddress, checkRevealed: LotteryState => Option[BigInt] ) extends ContractTestActor[LotteryTestState] { override def name: String = s"second-reveal-or-lose-${player.index}" override def actions(reader: BlockchainReader, state: LotteryTestState)(using ExecutionContext ): Future[Seq[StepAction]] = reader.currentSlot.flatMap { currentSlot => val slotTime = reader.cardanoInfo.slotConfig.slotToTime(currentSlot) val secrets = (info.secret1, info.secret2) state.games.get(secrets) match case Some(game) if slotTime < info.revealDeadline => checkRevealed(game.datum.lotteryState) match case Some(revealedLen) => val txs = Seq.newBuilder[Future[Option[Transaction]]] val totalLen = revealedLen.toInt + preimage.bytes.length if totalLen % 2 == 0 then val buildSecondReveal = if player == info.player1 then buildSecondRevealP1Tx else buildSecondRevealP2Tx txs += buildSecondReveal(reader, game.utxo, info) .map(Some(_)) .recover { case _: TxBuilderException => None } txs += buildLoseTx( reader, game.utxo, info, loser = player, loserPreimage = preimage, winnerAddr = opponentAddr ).map(Some(_)).recover { case _: TxBuilderException => None } Future .sequence(txs.result()) .map(_.flatten.map(StepAction.Submit(_))) case None => Future.successful(Seq.empty) case _ => Future.successful(Seq.empty) } } /** Actor that claims a timeout after the deadline has passed. */ class TimeoutActor(info: GameInfo, claimant: Participant, claimantPreimage: Preimage) extends ContractTestActor[LotteryTestState] { override def name: String = s"timeout-${claimant.index}" override def actions(reader: BlockchainReader, state: LotteryTestState)(using ExecutionContext ): Future[Seq[StepAction]] = reader.currentSlot.flatMap { currentSlot => val slotTime = reader.cardanoInfo.slotConfig.slotToTime(currentSlot) val secrets = (info.secret1, info.secret2) state.games.get(secrets) match case Some(game) if slotTime > info.revealDeadline && game.datum.lotteryState != LotteryState.Empty => buildTimeoutTx(reader, game.utxo, info, claimant, claimantPreimage) .map(tx => Seq(StepAction.Submit(tx))) .recover { case _: TxBuilderException => Seq.empty } case _ => Future.successful(Seq.empty) } } // ========================================================================= // Step (built from actors) // ========================================================================= private def makeLotteryStep( allGameInfos: IndexedSeq[GameInfo] ): ContractStepVariations[LotteryTestState] = { val gameInfoBySecrets: Map[(Secret, Secret), GameInfo] = allGameInfos.map(g => (g.secret1, g.secret2) -> g).toMap // Limit to 3 active games per step to keep the action space manageable val activeGameInfos = allGameInfos.take(3) val actors: Seq[ContractTestActor[LotteryTestState]] = activeGameInfos.flatMap { info => Seq( new RevealP1Actor(info), new RevealP2Actor(info), new SecondRevealOrLoseActor( info, player = info.player2, preimage = info.preimage2, opponentAddr = info.player1.address, checkRevealed = { case LotteryState.PlayerOneRevealed(len, _) => Some(len) case _ => None } ), new SecondRevealOrLoseActor( info, player = info.player1, preimage = info.preimage1, opponentAddr = info.player2.address, checkRevealed = { case LotteryState.PlayerTwoRevealed(len, _) => Some(len) case _ => None } ), new TimeoutActor(info, info.player1, info.preimage1), new TimeoutActor(info, info.player2, info.preimage2) ) } ContractStepVariations.fromActors[LotteryTestState]( extract = reader => findAllGameUtxos(reader).map { utxoMap => val games = utxoMap.flatMap { case (secrets, utxo) => gameInfoBySecrets.get(secrets).map { info => val datum = utxo.output.requireInlineDatum.to[State] secrets -> GameOnChain(utxo, datum, info) } } LotteryTestState(games) }, actors = actors, delays = _ => Seq(5L, 30L) ) } // ========================================================================= // Transaction builders // ========================================================================= private def buildRevealP1Tx( reader: BlockchainReader, utxo: Utxo, info: GameInfo )(using ExecutionContext): Future[Transaction] = { val p1Pkh = info.player1.addrKeyHash val newLotteryState = LotteryState.PlayerOneRevealed( BigInt(info.preimage1.bytes.length), PubKeyHash(p1Pkh) ) val newState = State( playerOneSecret = info.secret1, playerTwoSecret = info.secret2, revealDeadline = info.revealDeadline, lotteryState = newLotteryState ) val redeemer = Action.RevealPlayerOne(info.preimage1) TxBuilder(reader.cardanoInfo) .spend(utxo, redeemer, lotteryScript) .requireSignature(p1Pkh) .payTo(scriptAddress, utxo.output.value, newState) .validTo(Instant.ofEpochMilli(info.revealDeadline)) .complete(reader, info.player1.address) .map(_.sign(info.player1.signer).transaction) } private def buildRevealP2Tx( reader: BlockchainReader, utxo: Utxo, info: GameInfo )(using ExecutionContext): Future[Transaction] = { val p2Pkh = info.player2.addrKeyHash val newLotteryState = LotteryState.PlayerTwoRevealed( BigInt(info.preimage2.bytes.length), PubKeyHash(p2Pkh) ) val newState = State( playerOneSecret = info.secret1, playerTwoSecret = info.secret2, revealDeadline = info.revealDeadline, lotteryState = newLotteryState ) val redeemer = Action.RevealPlayerTwo(info.preimage2) TxBuilder(reader.cardanoInfo) .spend(utxo, redeemer, lotteryScript) .requireSignature(p2Pkh) .payTo(scriptAddress, utxo.output.value, newState) .validTo(Instant.ofEpochMilli(info.revealDeadline)) .complete(reader, info.player2.address) .map(_.sign(info.player2.signer).transaction) } private def buildSecondRevealP1Tx( reader: BlockchainReader, utxo: Utxo, info: GameInfo )(using ExecutionContext): Future[Transaction] = { val p1Pkh = info.player1.addrKeyHash val redeemer = Action.RevealPlayerOne(info.preimage1) TxBuilder(reader.cardanoInfo) .spend(utxo, redeemer, lotteryScript) .requireSignature(p1Pkh) .payTo(info.player1.address, utxo.output.value) .validTo(Instant.ofEpochMilli(info.revealDeadline)) .complete(reader, info.player1.address) .map(_.sign(info.player1.signer).transaction) } private def buildSecondRevealP2Tx( reader: BlockchainReader, utxo: Utxo, info: GameInfo )(using ExecutionContext): Future[Transaction] = { val p2Pkh = info.player2.addrKeyHash val redeemer = Action.RevealPlayerTwo(info.preimage2) TxBuilder(reader.cardanoInfo) .spend(utxo, redeemer, lotteryScript) .requireSignature(p2Pkh) .payTo(info.player2.address, utxo.output.value) .validTo(Instant.ofEpochMilli(info.revealDeadline)) .complete(reader, info.player2.address) .map(_.sign(info.player2.signer).transaction) } private def buildLoseTx( reader: BlockchainReader, utxo: Utxo, info: GameInfo, loser: Participant, loserPreimage: Preimage, winnerAddr: ShelleyAddress )(using ExecutionContext): Future[Transaction] = { val loserPkh = loser.addrKeyHash val redeemer: Transaction => Data = { (tx: Transaction) => val winnerOutputIdx = tx.body.value.outputs.indexWhere(_.value.address == winnerAddr) Action.Lose(loserPreimage, BigInt(winnerOutputIdx)).toData } TxBuilder(reader.cardanoInfo) .spend(utxo, redeemer, lotteryScript) .requireSignature(loserPkh) .payTo(winnerAddr, utxo.output.value) .complete(reader, loser.address) .map(_.sign(loser.signer).transaction) } private def buildTimeoutTx( reader: BlockchainReader, utxo: Utxo, info: GameInfo, claimant: Participant, claimantPreimage: Preimage )(using ExecutionContext): Future[Transaction] = { val claimantPkh = claimant.addrKeyHash val redeemer = Action.Timeout(claimantPreimage) TxBuilder(reader.cardanoInfo) .spend(utxo, redeemer, lotteryScript) .requireSignature(claimantPkh) .payTo(claimant.address, utxo.output.value) .validFrom(Instant.ofEpochMilli(info.revealDeadline + 1000)) .complete(reader, claimant.address) .map(_.sign(claimant.signer).transaction) } // ========================================================================= // Setup // ========================================================================= private def createEmulatorWithGames(): (Emulator, IndexedSeq[GameInfo]) = { given ExecutionContext = ExecutionContext.global val addresses = participants.flatMap(p => Seq.fill(3)(p.address)) val emulator = Emulator.withAddresses(addresses, Value.lovelace(50_000_000L)) emulator.setSlot(beforeDeadlineSlot) val deadline = emulator.cardanoInfo.slotConfig.slotToTime(deadlineSlot) val gameInfos = gameDefs.zipWithIndex.map { case ((p1Idx, p2Idx, _, _), gameIdx) => val p1 = participants(p1Idx) val p2 = participants(p2Idx) val (preimage1, preimage2, secret1, secret2) = gamePreimages(gameIdx) val datum = State( playerOneSecret = secret1, playerTwoSecret = secret2, revealDeadline = deadline, lotteryState = LotteryState.Empty ) val p1Utxos = Await.result( emulator.findUtxos(p1.address).map(_.getOrElse(Map.empty)), Duration.Inf ) val p2Utxos = Await.result( emulator.findUtxos(p2.address).map(_.getOrElse(Map.empty)), Duration.Inf ) val p1Utxo = Utxo(p1Utxos.head) val p2Utxo = Utxo(p2Utxos.head) val allUtxos = p1Utxos ++ p2Utxos val tx = TxBuilder(emulator.cardanoInfo) .spend(p1Utxo) .spend(p2Utxo) .payTo(scriptAddress, Value.lovelace(betAmount * 2), datum) .complete(availableUtxos = allUtxos, sponsor = p1.address) .sign(p1.signer) .sign(p2.signer) .transaction val submitResult = Await.result(emulator.submit(tx), Duration.Inf) assert(submitResult.isRight, s"Game $gameIdx creation failed: $submitResult") GameInfo( secret1 = secret1, secret2 = secret2, preimage1 = preimage1, preimage2 = preimage2, player1 = p1, player2 = p2, betAmount = betAmount * 2, revealDeadline = deadline ) } (emulator, gameInfos) } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/lottery/LotteryScenarioTest.scala ```scala package scalus.examples.lottery import cps.* import org.scalacheck.{Arbitrary, Gen} import org.scalatest.funsuite.AnyFunSuite import scalus.cardano.address.Network import scalus.cardano.ledger.* import scalus.cardano.node.{BlockchainReader, Emulator} import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.cardano.txbuilder.TxBuilder import scalus.testing.* import scalus.testing.kit.Party import scalus.testing.kit.Party.* import scalus.uplc.builtin.Builtins.sha2_256 import scalus.uplc.builtin.{ByteString, Data} import scalus.uplc.builtin.Data.toData import java.time.Instant import scala.concurrent.{Await, Future} import scala.concurrent.duration.Duration import scala.util.{Failure, Success} /** Scenario exploration test for lottery contract. * * Explores 5 concurrent lottery games with overlapping players. Each game has unique preimages and * secrets. Non-deterministic branching explores reveal, lose, timeout, and wait actions across all * games, checking invariants after each step. */ class LotteryScenarioTest extends AnyFunSuite { import LotteryScenarioTest.* test( "explore: lottery invariants hold under non-deterministic actions with 5 concurrent games" ) { val emulator = createEmulator() val scenario = async[Scenario] { // Create all 5 games val gameInfos = games.zipWithIndex.map { case (g, idx) => createGameS(g).await } Scenario .explore(maxDepth = 5) { _ => async[Scenario] { // Pick a random game val gameIdx = Scenario.sample(Gen.choose(0, 4)).await val info = gameInfos(gameIdx) val action = Scenario .choices( "reveal_p1", "reveal_p2", "lose", "timeout", "wait_short", "wait_long" ) .await action match case "reveal_p1" => tryRevealP1S(info).await case "reveal_p2" => tryRevealP2S(info).await case "lose" => tryLoseS(info).await case "timeout" => tryTimeoutS(info).await case "wait_short" => Scenario.sleep(5).await case "wait_long" => Scenario.sleep(30).await // Check invariants for ALL games val reader = Scenario.snapshotReader.await gameInfos.foreach { gi => checkGameInvariantsS(reader, gi).await } } } .await } val results = Await.result(Scenario.runAll(emulator)(scenario), Duration(180, "s")) val violations = results.flatMap(_._2) assert( violations.isEmpty, s"Found violations: ${violations.map(v => s"${v.message} at ${v.location}")}" ) } } object LotteryScenarioTest { import Scenario.futureToScenarioConversion private val compiledContract = LotteryContract.compiled.withErrorTraces private val lotteryScript = compiledContract.script private val network = Network.Mainnet private val scriptAddress = compiledContract.address(network) // Deadline and slot timing private val deadlineSlot: Long = 20L private val beforeDeadlineSlot: Long = deadlineSlot - 5 // ========================================================================= // Game definitions — 5 games with overlapping players // ========================================================================= case class GameDef( player1: Party, player2: Party, preimage1Len: Int, preimage2Len: Int ) case class GameInfo( player1: Party, player2: Party, preimage1: Preimage, preimage2: Preimage, secret1: Secret, secret2: Secret, betAmount: Long, revealDeadline: Long // slot-time millis ) private def genByteStringOfN(n: Int): Gen[ByteString] = Gen.containerOfN[Array, Byte](n, Arbitrary.arbitrary[Byte]) .map(a => ByteString.unsafeFromArray(a)) // Games with overlapping players: // Game 0: Alice vs Bob (even sum: 32+16=48) // Game 1: Alice vs Charles (odd sum: 32+17=49) // Game 2: Bob vs Charles (even sum: 16+20=36) // Game 3: Dave vs Eve (odd sum: 32+15=47) // Game 4: Dave vs Alice (even sum: 20+16=36) private val games: IndexedSeq[GameDef] = IndexedSeq( GameDef(Alice, Bob, 32, 16), GameDef(Alice, Charles, 32, 17), GameDef(Bob, Charles, 16, 20), GameDef(Dave, Eve, 32, 15), GameDef(Dave, Alice, 20, 16) ) private val betAmount = 5_000_000L // Generate preimages and secrets for each game private val preimagesAndSecrets: IndexedSeq[(Preimage, Preimage, Secret, Secret)] = games.map { g => val p1 = genByteStringOfN(g.preimage1Len).sample.get val p2 = genByteStringOfN(g.preimage2Len).sample.get (p1, p2, sha2_256(p1), sha2_256(p2)) } // ========================================================================= // Emulator setup // ========================================================================= private def createEmulator(): Emulator = { val parties = Seq(Alice, Bob, Charles, Dave, Eve) val utxosPerParty = 6 val addresses = parties.flatMap(p => Seq.fill(utxosPerParty)(p.address(network))) val emulator = Emulator.withAddresses(addresses, Value.lovelace(50_000_000L)) emulator.setSlot(beforeDeadlineSlot) emulator } // ========================================================================= // Helpers // ========================================================================= private def findGameUtxo( reader: BlockchainReader, secrets: (Secret, Secret) ): Future[Option[Utxo]] = { given scala.concurrent.ExecutionContext = reader.executionContext reader.findUtxos(scriptAddress).map(_.getOrElse(Map.empty)).map { utxos => utxos .find { (_, output) => output.inlineDatum.exists { d => scala.util .Try { val state = d.to[State] state.playerOneSecret == secrets._1 && state.playerTwoSecret == secrets._2 } .getOrElse(false) } } .map((i, o) => Utxo(i, o)) } } private def getGameDatum(utxo: Utxo): State = utxo.output.requireInlineDatum.to[State] // ========================================================================= // Game creation // ========================================================================= private def createGameS(gameDef: GameDef): Scenario[GameInfo] = async[Scenario] { val reader = Scenario.snapshotReader.await given scala.concurrent.ExecutionContext = reader.executionContext val idx = games.indexOf(gameDef) val (preimage1, preimage2, secret1, secret2) = preimagesAndSecrets(idx) val deadline = reader.cardanoInfo.slotConfig.slotToTime(deadlineSlot) val p1Addr = gameDef.player1.address(network) val p2Addr = gameDef.player2.address(network) val p1Utxos = reader.findUtxos(p1Addr).await.getOrElse(Map.empty) val p2Utxos = reader.findUtxos(p2Addr).await.getOrElse(Map.empty) val datum = State( playerOneSecret = secret1, playerTwoSecret = secret2, revealDeadline = deadline, lotteryState = LotteryState.Empty ) val p1Utxo = Utxo(p1Utxos.head) val p2Utxo = Utxo(p2Utxos.head) val allUtxos = p1Utxos ++ p2Utxos val tx = TxBuilder(reader.cardanoInfo) .spend(p1Utxo) .spend(p2Utxo) .payTo(scriptAddress, Value.lovelace(betAmount * 2), datum) .complete(availableUtxos = allUtxos, sponsor = p1Addr) .sign(gameDef.player1.signer) .sign(gameDef.player2.signer) .transaction Scenario.submit(tx).await match case Right(_) => () case Left(err) => throw RuntimeException(s"Failed to create game $idx: $err") GameInfo( player1 = gameDef.player1, player2 = gameDef.player2, preimage1 = preimage1, preimage2 = preimage2, secret1 = secret1, secret2 = secret2, betAmount = betAmount * 2, revealDeadline = deadline ) } // ========================================================================= // Actions // ========================================================================= private def revealP1S(info: GameInfo): Scenario[Unit] = async[Scenario] { val reader = Scenario.snapshotReader.await given scala.concurrent.ExecutionContext = reader.executionContext val utxo = findGameUtxo(reader, (info.secret1, info.secret2)).await .getOrElse(throw RuntimeException("Game not found")) val p1Addr = info.player1.address(network) val p1Pkh = info.player1.addrKeyHash val newLotteryState = LotteryState.PlayerOneRevealed( BigInt(info.preimage1.bytes.length), PubKeyHash(p1Pkh) ) val newState = State( playerOneSecret = info.secret1, playerTwoSecret = info.secret2, revealDeadline = info.revealDeadline, lotteryState = newLotteryState ) val redeemer = Action.RevealPlayerOne(info.preimage1) val tx = TxBuilder(reader.cardanoInfo) .spend(utxo, redeemer, lotteryScript) .requireSignature(p1Pkh) .payTo(scriptAddress, utxo.output.value, newState) .validTo(Instant.ofEpochMilli(info.revealDeadline)) .complete(reader, p1Addr) .await .sign(info.player1.signer) .transaction Scenario.submit(tx).await match case Right(_) => () case Left(err) => throw RuntimeException(s"RevealP1 failed: $err") } private def revealP2S(info: GameInfo): Scenario[Unit] = async[Scenario] { val reader = Scenario.snapshotReader.await given scala.concurrent.ExecutionContext = reader.executionContext val utxo = findGameUtxo(reader, (info.secret1, info.secret2)).await .getOrElse(throw RuntimeException("Game not found")) val p2Addr = info.player2.address(network) val p2Pkh = info.player2.addrKeyHash val newLotteryState = LotteryState.PlayerTwoRevealed( BigInt(info.preimage2.bytes.length), PubKeyHash(p2Pkh) ) val newState = State( playerOneSecret = info.secret1, playerTwoSecret = info.secret2, revealDeadline = info.revealDeadline, lotteryState = newLotteryState ) val redeemer = Action.RevealPlayerTwo(info.preimage2) val tx = TxBuilder(reader.cardanoInfo) .spend(utxo, redeemer, lotteryScript) .requireSignature(p2Pkh) .payTo(scriptAddress, utxo.output.value, newState) .validTo(Instant.ofEpochMilli(info.revealDeadline)) .complete(reader, p2Addr) .await .sign(info.player2.signer) .transaction Scenario.submit(tx).await match case Right(_) => () case Left(err) => throw RuntimeException(s"RevealP2 failed: $err") } private def secondRevealP1S(info: GameInfo): Scenario[Unit] = async[Scenario] { val reader = Scenario.snapshotReader.await given scala.concurrent.ExecutionContext = reader.executionContext val utxo = findGameUtxo(reader, (info.secret1, info.secret2)).await .getOrElse(throw RuntimeException("Game not found")) val p1Addr = info.player1.address(network) val p1Pkh = info.player1.addrKeyHash val redeemer = Action.RevealPlayerOne(info.preimage1) val tx = TxBuilder(reader.cardanoInfo) .spend(utxo, redeemer, lotteryScript) .requireSignature(p1Pkh) .payTo(p1Addr, utxo.output.value) .validTo(Instant.ofEpochMilli(info.revealDeadline)) .complete(reader, p1Addr) .await .sign(info.player1.signer) .transaction Scenario.submit(tx).await match case Right(_) => () case Left(err) => throw RuntimeException(s"SecondRevealP1 failed: $err") } private def secondRevealP2S(info: GameInfo): Scenario[Unit] = async[Scenario] { val reader = Scenario.snapshotReader.await given scala.concurrent.ExecutionContext = reader.executionContext val utxo = findGameUtxo(reader, (info.secret1, info.secret2)).await .getOrElse(throw RuntimeException("Game not found")) val p2Addr = info.player2.address(network) val p2Pkh = info.player2.addrKeyHash val redeemer = Action.RevealPlayerTwo(info.preimage2) val tx = TxBuilder(reader.cardanoInfo) .spend(utxo, redeemer, lotteryScript) .requireSignature(p2Pkh) .payTo(p2Addr, utxo.output.value) .validTo(Instant.ofEpochMilli(info.revealDeadline)) .complete(reader, p2Addr) .await .sign(info.player2.signer) .transaction Scenario.submit(tx).await match case Right(_) => () case Left(err) => throw RuntimeException(s"SecondRevealP2 failed: $err") } private def loseS(info: GameInfo): Scenario[Unit] = async[Scenario] { val reader = Scenario.snapshotReader.await given scala.concurrent.ExecutionContext = reader.executionContext val utxo = findGameUtxo(reader, (info.secret1, info.secret2)).await .getOrElse(throw RuntimeException("Game not found")) val datum = getGameDatum(utxo) val (loserParty, loserPreimage, winnerAddr) = datum.lotteryState match case LotteryState.PlayerOneRevealed(_, _) => (info.player2, info.preimage2, info.player1.address(network)) case LotteryState.PlayerTwoRevealed(_, _) => (info.player1, info.preimage1, info.player2.address(network)) case _ => throw RuntimeException("Cannot lose from Empty state") val loserAddr = loserParty.address(network) val loserPkh = loserParty.addrKeyHash val redeemer: Transaction => Data = { (tx: Transaction) => val winnerOutputIdx = tx.body.value.outputs.indexWhere(_.value.address == winnerAddr) Action.Lose(loserPreimage, BigInt(winnerOutputIdx)).toData } val tx = TxBuilder(reader.cardanoInfo) .spend(utxo, redeemer, lotteryScript) .requireSignature(loserPkh) .payTo(winnerAddr, utxo.output.value) .complete(reader, loserAddr) .await .sign(loserParty.signer) .transaction Scenario.submit(tx).await match case Right(_) => () case Left(err) => throw RuntimeException(s"Lose failed: $err") } private def timeoutS(info: GameInfo): Scenario[Unit] = async[Scenario] { val reader = Scenario.snapshotReader.await given scala.concurrent.ExecutionContext = reader.executionContext val utxo = findGameUtxo(reader, (info.secret1, info.secret2)).await .getOrElse(throw RuntimeException("Game not found")) val datum = getGameDatum(utxo) val (claimantParty, claimantPreimage) = datum.lotteryState match case LotteryState.PlayerOneRevealed(_, _) => (info.player1, info.preimage1) case LotteryState.PlayerTwoRevealed(_, _) => (info.player2, info.preimage2) case _ => throw RuntimeException("Cannot timeout from Empty state") val claimantAddr = claimantParty.address(network) val claimantPkh = claimantParty.addrKeyHash val redeemer = Action.Timeout(claimantPreimage) val tx = TxBuilder(reader.cardanoInfo) .spend(utxo, redeemer, lotteryScript) .requireSignature(claimantPkh) .payTo(claimantAddr, utxo.output.value) .validFrom(Instant.ofEpochMilli(info.revealDeadline + 1000)) .complete(reader, claimantAddr) .await .sign(claimantParty.signer) .transaction Scenario.submit(tx).await match case Right(_) => () case Left(err) => throw RuntimeException(s"Timeout failed: $err") } // ========================================================================= // Try-action wrappers (precondition checking) // ========================================================================= private def tryActionS( actionName: String, shouldSucceed: Scenario[Boolean], action: Scenario[Unit] ): Scenario[Unit] = async[Scenario] { val expected = shouldSucceed.await Scenario.scenarioLogicMonad .flatMapTry(action) { case Success(_) => Scenario.check(expected, s"$actionName succeeded but preconditions not met") case Failure(ex) => Scenario.check( !expected, s"$actionName failed but preconditions were met: ${ex.getMessage}" ) } .await } private def tryRevealP1S(info: GameInfo): Scenario[Unit] = tryActionS( "reveal_p1", shouldSucceed = async[Scenario] { val reader = Scenario.snapshotReader.await val maybeUtxo = findGameUtxo(reader, (info.secret1, info.secret2)).await val slotTime = reader.cardanoInfo.slotConfig.slotToTime(reader.currentSlot.await) maybeUtxo.exists { utxo => val datum = getGameDatum(utxo) val beforeDeadline = slotTime < info.revealDeadline datum.lotteryState match case LotteryState.Empty => beforeDeadline case LotteryState.PlayerTwoRevealed(p2Len, _) => val totalLen = p2Len.toInt + info.preimage1.bytes.length beforeDeadline && (totalLen % 2 == 0) case _ => false } }, action = async[Scenario] { val reader = Scenario.snapshotReader.await val maybeUtxo = findGameUtxo(reader, (info.secret1, info.secret2)).await maybeUtxo match case Some(utxo) => val datum = getGameDatum(utxo) datum.lotteryState match case LotteryState.Empty => revealP1S(info).await case LotteryState.PlayerTwoRevealed(_, _) => secondRevealP1S(info).await case _ => throw RuntimeException("Cannot reveal P1 in this state") case None => throw RuntimeException("Game not found") } ) private def tryRevealP2S(info: GameInfo): Scenario[Unit] = tryActionS( "reveal_p2", shouldSucceed = async[Scenario] { val reader = Scenario.snapshotReader.await val maybeUtxo = findGameUtxo(reader, (info.secret1, info.secret2)).await val slotTime = reader.cardanoInfo.slotConfig.slotToTime(reader.currentSlot.await) maybeUtxo.exists { utxo => val datum = getGameDatum(utxo) val beforeDeadline = slotTime < info.revealDeadline datum.lotteryState match case LotteryState.Empty => beforeDeadline case LotteryState.PlayerOneRevealed(p1Len, _) => val totalLen = p1Len.toInt + info.preimage2.bytes.length beforeDeadline && (totalLen % 2 == 0) case _ => false } }, action = async[Scenario] { val reader = Scenario.snapshotReader.await val maybeUtxo = findGameUtxo(reader, (info.secret1, info.secret2)).await maybeUtxo match case Some(utxo) => val datum = getGameDatum(utxo) datum.lotteryState match case LotteryState.Empty => revealP2S(info).await case LotteryState.PlayerOneRevealed(_, _) => secondRevealP2S(info).await case _ => throw RuntimeException("Cannot reveal P2 in this state") case None => throw RuntimeException("Game not found") } ) private def tryLoseS(info: GameInfo): Scenario[Unit] = tryActionS( "lose", shouldSucceed = async[Scenario] { val reader = Scenario.snapshotReader.await val maybeUtxo = findGameUtxo(reader, (info.secret1, info.secret2)).await maybeUtxo.exists { utxo => val datum = getGameDatum(utxo) datum.lotteryState match case LotteryState.PlayerOneRevealed(_, _) => true case LotteryState.PlayerTwoRevealed(_, _) => true case _ => false } }, action = loseS(info) ) private def tryTimeoutS(info: GameInfo): Scenario[Unit] = tryActionS( "timeout", shouldSucceed = async[Scenario] { val reader = Scenario.snapshotReader.await val slotTime = reader.cardanoInfo.slotConfig.slotToTime(reader.currentSlot.await) val pastDeadline = slotTime > info.revealDeadline val maybeUtxo = findGameUtxo(reader, (info.secret1, info.secret2)).await pastDeadline && maybeUtxo.exists { utxo => val datum = getGameDatum(utxo) datum.lotteryState match case LotteryState.PlayerOneRevealed(_, _) => true case LotteryState.PlayerTwoRevealed(_, _) => true case _ => false } }, action = timeoutS(info) ) // ========================================================================= // Invariant checking // ========================================================================= private def checkGameInvariantsS(reader: BlockchainReader, info: GameInfo): Scenario[Unit] = async[Scenario] { val maybeUtxo = findGameUtxo(reader, (info.secret1, info.secret2)).await maybeUtxo match case Some(utxo) => val datum = getGameDatum(utxo) Scenario .check( datum.playerOneSecret == info.secret1, "playerOneSecret must not change" ) .await Scenario .check( datum.playerTwoSecret == info.secret2, "playerTwoSecret must not change" ) .await Scenario .check( datum.revealDeadline == info.revealDeadline, "revealDeadline must not change" ) .await Scenario .check( utxo.output.value.coin.value >= info.betAmount, "pot value must be at least original bet" ) .await case None => () // game consumed (completed) is ok } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/lottery/LotteryValidatorTest.scala ```scala package scalus.examples.lottery import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.Builtins.sha2_256 import scalus.uplc.builtin.Data.toData import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.Context import scalus.cardano.node.{Emulator, SubmitError} import scalus.cardano.txbuilder.RedeemerPurpose import scalus.cardano.onchain.plutus.v3.ScriptContext import scalus.testing.kit.Party.{Alice, Bob, Eve} import scalus.testing.kit.TestUtil.{genesisHash, getScriptContextV3} import scalus.testing.kit.{ScalusTest, TestUtil} import scalus.utils.await import java.time.Instant import scala.util.Try class LotteryValidatorTest extends AnyFunSuite, ScalusTest { import LotteryValidatorTest.{*, given} test(s"Lottery validator size is ${LotteryContract.compiled.script.script.size} bytes") { info(s"Validator size: ${LotteryContract.compiled.script.script.size} bytes") } test("P1 reveals valid preimage from Empty state") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) val utxos = provider.findUtxos(Alice.address).await().toOption.get val revealTx = txCreator.revealPlayerOne( utxos = utxos, lotteryUtxo = lotteryUtxo, preimage = validPreimage1, playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) assertSuccess( provider, revealTx, lotteryUtxo._1, ExUnits(memory = 110607, steps = 35_991542) ) } test("P2 reveals valid preimage from Empty state") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) val utxos = provider.findUtxos(Bob.address).await().toOption.get val revealTx = txCreator.revealPlayerTwo( utxos = utxos, lotteryUtxo = lotteryUtxo, preimage = validPreimage2, playerTwoPkh = Bob.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Bob.address, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) assertSuccess( provider, revealTx, lotteryUtxo._1, ExUnits(memory = 110839, steps = 36_060029) ) } test("P1 reveal succeeds at deadline (boundary)") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) val utxos = provider.findUtxos(Alice.address).await().toOption.get val revealTx = txCreator.revealPlayerOne( utxos = utxos, lotteryUtxo = lotteryUtxo, preimage = validPreimage1, playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = afterDeadline, // validTo is exclusive, so use slot 11 to include slot 10 signer = Alice.signer ) provider.setSlot(deadlineSlot) assertSuccess( provider, revealTx, lotteryUtxo._1, ExUnits(memory = 110607, steps = 35_991542) ) } test("FAIL: P1 reveal with wrong preimage") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) val utxos = provider.findUtxos(Alice.address).await().toOption.get val revealTx = txCreator.revealPlayerOne( utxos = utxos, lotteryUtxo = lotteryUtxo, preimage = wrongPreimage, playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) assertFailure(provider, revealTx, lotteryUtxo._1, "Fraudulent attempt") } test("FAIL: P2 reveal with wrong preimage") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) val utxos = provider.findUtxos(Bob.address).await().toOption.get val revealTx = txCreator.revealPlayerTwo( utxos = utxos, lotteryUtxo = lotteryUtxo, preimage = wrongPreimage, playerTwoPkh = Bob.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Bob.address, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) assertFailure(provider, revealTx, lotteryUtxo._1, "Fraudulent attempt") } test("FAIL: P1 reveal attempts to change player two secret") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) val utxos = provider.findUtxos(Alice.address).await().toOption.get // Create a different secret that P1 will try to substitute val maliciousSecret = sha2_256(genByteStringOfN(20).sample.get) val revealTx = txCreator.revealPlayerOne( utxos = utxos, lotteryUtxo = lotteryUtxo, preimage = validPreimage1, playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = maliciousSecret, // Trying to change P2's secret! revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) assertFailure(provider, revealTx, lotteryUtxo._1, "Player two secret must not change") } test("FAIL: P2 reveal attempts to change deadline") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) val utxos = provider.findUtxos(Bob.address).await().toOption.get // Try to extend the deadline val extendedDeadline = afterDeadline.toEpochMilli val revealTx = txCreator.revealPlayerTwo( utxos = utxos, lotteryUtxo = lotteryUtxo, preimage = validPreimage2, playerTwoPkh = Bob.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = extendedDeadline, // Trying to change deadline! sponsor = Bob.address, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) assertFailure(provider, revealTx, lotteryUtxo._1, "Reveal deadline must not change") } test("P2 reveals with even sum (32 + 16 = 48)") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) // P1 reveals first (32 bytes) val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val p1RevealTx = txCreator.revealPlayerOne( utxos = utxos1, lotteryUtxo = lotteryUtxo, preimage = validPreimage1, // 32 bytes playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) provider.submit(p1RevealTx).await() val lotteryUtxo2 = Utxo(p1RevealTx.utxos.find(_._2.address == scriptAddress).get) // P2 reveals (16 bytes) -> 32 + 16 = 48 (even) -> succeeds val utxos2 = provider.findUtxos(Bob.address).await().toOption.get val p2RevealTx = txCreator.revealPlayerTwo( utxos = utxos2, lotteryUtxo = lotteryUtxo2, preimage = validPreimage2, // 16 bytes playerTwoPkh = Bob.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Bob.address, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) assertSuccess( provider, p2RevealTx, lotteryUtxo2._1, ExUnits(memory = 74074, steps = 23_417199) ) } // Timeout Tests test("P2 timeout after P2 revealed but P1 didn't reveal") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) // P2 reveals first val utxos1 = provider.findUtxos(Bob.address).await().toOption.get val p2RevealTx = txCreator.revealPlayerTwo( utxos = utxos1, lotteryUtxo = lotteryUtxo, preimage = validPreimage2, playerTwoPkh = Bob.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Bob.address, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) provider.submit(p2RevealTx).await() val lotteryUtxo2 = Utxo(p2RevealTx.utxos.find(_._2.address == scriptAddress).get) // P2 (who revealed) claims timeout pot after P1 failed to reveal before deadline val utxos2 = provider.findUtxos(Bob.address).await().toOption.get val timeoutTx = txCreator.timeout( utxos = utxos2, lotteryUtxo = lotteryUtxo2, preimage = validPreimage2, // P2's preimage (the one who revealed) claimantPkh = Bob.addrKeyHash, payeeAddress = Bob.address, sponsor = Bob.address, validFrom = afterDeadline, signer = Bob.signer ) provider.setSlot(afterSlot) assertSuccess( provider, timeoutTx, lotteryUtxo2._1, ExUnits(memory = 96553, steps = 28_875150) ) } test("FAIL: Timeout before deadline") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) // P2 reveals first val utxos1 = provider.findUtxos(Bob.address).await().toOption.get val p2RevealTx = txCreator.revealPlayerTwo( utxos = utxos1, lotteryUtxo = lotteryUtxo, preimage = validPreimage2, playerTwoPkh = Bob.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Bob.address, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) provider.submit(p2RevealTx).await() val lotteryUtxo2 = Utxo(p2RevealTx.utxos.find(_._2.address == scriptAddress).get) // P2 tries to timeout before deadline - should fail val utxos2 = provider.findUtxos(Bob.address).await().toOption.get val timeoutTx = txCreator.timeout( utxos = utxos2, lotteryUtxo = lotteryUtxo2, preimage = validPreimage2, // P2's preimage (the one who revealed) claimantPkh = Bob.addrKeyHash, payeeAddress = Bob.address, sponsor = Bob.address, validFrom = beforeDeadline, signer = Bob.signer ) provider.setSlot(beforeSlot) assertFailure(provider, timeoutTx, lotteryUtxo2._1, "Deadline not reached") } // Lose/Concede test("P2 concedes after P1 revealed - P1 gets pot") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) // P1 reveals first val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val p1RevealTx = txCreator.revealPlayerOne( utxos = utxos1, lotteryUtxo = lotteryUtxo, preimage = validPreimage1, playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) provider.submit(p1RevealTx).await() val lotteryUtxo2 = Utxo(p1RevealTx.utxos.find(_._2.address == scriptAddress).get) // P2 concedes - gives pot to P1 val utxos2 = provider.findUtxos(Bob.address).await().toOption.get val loseTx = txCreator.lose( utxos = utxos2, lotteryUtxo = lotteryUtxo2, preimage = validPreimage2, loserPkh = Bob.addrKeyHash, winnerAddress = Alice.address, winnerOutputIdx = BigInt(0), // Assuming first output is to Alice sponsor = Bob.address, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) assertSuccess( provider, loseTx, lotteryUtxo2._1, ExUnits(memory = 93843, steps = 28_681496) ) } test("P1 timeout after P1 revealed but P2 didn't reveal") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) // P1 reveals first val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val p1RevealTx = txCreator.revealPlayerOne( utxos = utxos1, lotteryUtxo = lotteryUtxo, preimage = validPreimage1, playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) provider.submit(p1RevealTx).await() val lotteryUtxo2 = Utxo(p1RevealTx.utxos.find(_._2.address == scriptAddress).get) // P1 (who revealed) claims timeout pot after P2 failed to reveal before deadline val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val timeoutTx = txCreator.timeout( utxos = utxos2, lotteryUtxo = lotteryUtxo2, preimage = validPreimage1, // P1's preimage (the one who revealed) claimantPkh = Alice.addrKeyHash, payeeAddress = Alice.address, sponsor = Alice.address, validFrom = afterDeadline, signer = Alice.signer ) provider.setSlot(afterSlot) assertSuccess( provider, timeoutTx, lotteryUtxo2._1, ExUnits(memory = 96321, steps = 28_806663) ) } test("FAIL: a third party cannot steal the pot via Timeout using the public preimage") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) // P1 reveals, publishing validPreimage1 on-chain (it is now public to everyone). val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val p1RevealTx = txCreator.revealPlayerOne( utxos = utxos1, lotteryUtxo = lotteryUtxo, preimage = validPreimage1, playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) provider.submit(p1RevealTx).await() val lotteryUtxo2 = Utxo(p1RevealTx.utxos.find(_._2.address == scriptAddress).get) // Eve (neither player) supplies the now-public preimage and directs the pot to herself // after the deadline. The validator must reject this: a Timeout must pay the revealer (P1). val utxos2 = provider.findUtxos(Eve.address).await().toOption.get val theftTx = txCreator.timeout( utxos = utxos2, lotteryUtxo = lotteryUtxo2, preimage = validPreimage1, claimantPkh = Eve.addrKeyHash, payeeAddress = Eve.address, sponsor = Eve.address, validFrom = afterDeadline, signer = Eve.signer ) provider.setSlot(afterSlot) assertFailure(provider, theftTx, lotteryUtxo2._1, "Timeout must pay the revealer") } test("FAIL: P2 reveals with odd sum (32 + 17 = 49) - Unlucky") { val provider = createProvider() // Create odd-length preimage (17 bytes) for P2 val oddPreimage = genByteStringOfN(17).sample.get val oddSecret = sha2_256(oddPreimage) // Create lottery with P1 having 32-byte preimage and P2 having 17-byte preimage val aliceUtxos = provider.findUtxos(address = Alice.address).await().toOption.get val bobUtxos = provider.findUtxos(address = Bob.address).await().toOption.get val initiateTx = txCreator.initiateLottery( playerOneUtxos = aliceUtxos, playerTwoUtxos = bobUtxos, betAmount = betAmount, playerOnePkh = Alice.addrKeyHash, playerTwoPkh = Bob.addrKeyHash, secret1 = validSecret1, // 32-byte preimage secret2 = oddSecret, // 17-byte preimage revealDeadline = deadline.toEpochMilli, changeAddress = Alice.address, playerOneSigner = Alice.signer, playerTwoSigner = Bob.signer ) provider.submit(initiateTx).await() val lotteryUtxo = Utxo(initiateTx.utxos.find(_._2.address == scriptAddress).get) // P1 reveals first (32 bytes) val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val p1RevealTx = txCreator.revealPlayerOne( utxos = utxos1, lotteryUtxo = lotteryUtxo, preimage = validPreimage1, // 32 bytes playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = oddSecret, revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) provider.submit(p1RevealTx).await() val lotteryUtxo2 = Utxo(p1RevealTx.utxos.find(_._2.address == scriptAddress).get) // P2 reveals (17 bytes) -> 32 + 17 = 49 (odd) -> fails with "Unlucky" val utxos2 = provider.findUtxos(Bob.address).await().toOption.get val p2RevealTx = txCreator.revealPlayerTwo( utxos = utxos2, lotteryUtxo = lotteryUtxo2, preimage = oddPreimage, // 17 bytes playerTwoPkh = Bob.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = oddSecret, revealDeadline = deadline.toEpochMilli, sponsor = Bob.address, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) assertFailure(provider, p2RevealTx, lotteryUtxo2._1, "Unlucky") } test("P1 concedes after P2 revealed - P2 gets pot") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) // P2 reveals first val utxos1 = provider.findUtxos(Bob.address).await().toOption.get val p2RevealTx = txCreator.revealPlayerTwo( utxos = utxos1, lotteryUtxo = lotteryUtxo, preimage = validPreimage2, playerTwoPkh = Bob.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Bob.address, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) provider.submit(p2RevealTx).await() val lotteryUtxo2 = Utxo(p2RevealTx.utxos.find(_._2.address == scriptAddress).get) // P1 concedes - gives pot to P2 val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val loseTx = txCreator.lose( utxos = utxos2, lotteryUtxo = lotteryUtxo2, preimage = validPreimage1, loserPkh = Alice.addrKeyHash, winnerAddress = Bob.address, winnerOutputIdx = BigInt(0), // Assuming first output is to Bob sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) assertSuccess( provider, loseTx, lotteryUtxo2._1, ExUnits(memory = 93611, steps = 28_613009) ) } test("FAIL: P2 second reveal with wrong preimage") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) // P1 reveals first val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val p1RevealTx = txCreator.revealPlayerOne( utxos = utxos1, lotteryUtxo = lotteryUtxo, preimage = validPreimage1, playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) provider.submit(p1RevealTx).await() val lotteryUtxo2 = Utxo(p1RevealTx.utxos.find(_._2.address == scriptAddress).get) // P2 tries to reveal with wrong preimage val utxos2 = provider.findUtxos(Bob.address).await().toOption.get val p2RevealTx = txCreator.revealPlayerTwo( utxos = utxos2, lotteryUtxo = lotteryUtxo2, preimage = wrongPreimage, playerTwoPkh = Bob.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Bob.address, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) assertFailure(provider, p2RevealTx, lotteryUtxo2._1, "Fraudulent attempt") } test("FAIL: P2 lose with wrong preimage") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) // P1 reveals first val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val p1RevealTx = txCreator.revealPlayerOne( utxos = utxos1, lotteryUtxo = lotteryUtxo, preimage = validPreimage1, playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) provider.submit(p1RevealTx).await() val lotteryUtxo2 = Utxo(p1RevealTx.utxos.find(_._2.address == scriptAddress).get) // P2 tries to concede with wrong preimage val utxos2 = provider.findUtxos(Bob.address).await().toOption.get val loseTx = txCreator.lose( utxos = utxos2, lotteryUtxo = lotteryUtxo2, preimage = wrongPreimage, loserPkh = Bob.addrKeyHash, winnerAddress = Alice.address, winnerOutputIdx = BigInt(0), sponsor = Bob.address, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) assertFailure(provider, loseTx, lotteryUtxo2._1, "Fraudulent attempt") } test("FAIL: P1 timeout with wrong preimage") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) // P1 reveals first val utxos1 = provider.findUtxos(Alice.address).await().toOption.get val p1RevealTx = txCreator.revealPlayerOne( utxos = utxos1, lotteryUtxo = lotteryUtxo, preimage = validPreimage1, playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) provider.submit(p1RevealTx).await() val lotteryUtxo2 = Utxo(p1RevealTx.utxos.find(_._2.address == scriptAddress).get) // P1 tries to timeout with wrong preimage val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val timeoutTx = txCreator.timeout( utxos = utxos2, lotteryUtxo = lotteryUtxo2, preimage = wrongPreimage, claimantPkh = Alice.addrKeyHash, payeeAddress = Alice.address, sponsor = Alice.address, validFrom = afterDeadline, signer = Alice.signer ) provider.setSlot(afterSlot) assertFailure(provider, timeoutTx, lotteryUtxo2._1, "Fraudulent attempt") } test("P1 reveals with even sum (32 + 16 = 48) after P2 revealed") { val provider = createProvider() val (_, lotteryUtxo) = createAndSubmitInitiateTx(provider) // P2 reveals first (16 bytes) val utxos1 = provider.findUtxos(Bob.address).await().toOption.get val p2RevealTx = txCreator.revealPlayerTwo( utxos = utxos1, lotteryUtxo = lotteryUtxo, preimage = validPreimage2, // 16 bytes playerTwoPkh = Bob.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Bob.address, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) provider.submit(p2RevealTx).await() val lotteryUtxo2 = Utxo(p2RevealTx.utxos.find(_._2.address == scriptAddress).get) // P1 reveals (32 bytes) -> 16 + 32 = 48 (even) -> succeeds val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val p1RevealTx = txCreator.revealPlayerOne( utxos = utxos2, lotteryUtxo = lotteryUtxo2, preimage = validPreimage1, // 32 bytes playerOnePkh = Alice.addrKeyHash, playerOneSecret = validSecret1, playerTwoSecret = validSecret2, revealDeadline = deadline.toEpochMilli, sponsor = Alice.address, validTo = deadline, signer = Alice.signer ) provider.setSlot(beforeSlot) assertSuccess( provider, p1RevealTx, lotteryUtxo2._1, ExUnits(memory = 73842, steps = 23_348712) ) } } object LotteryValidatorTest extends ScalusTest { private given env: CardanoInfo = TestUtil.testEnvironment private val compiledContract = LotteryContract.compiled.withErrorTraces private val scriptAddress = compiledContract.address(env.network) private val txCreator = LotteryTransactions( env = env, evaluator = PlutusScriptEvaluator.constMaxBudget(env), contract = compiledContract ) // Time constants private val deadlineSlot: SlotNo = 10 private val beforeSlot: SlotNo = deadlineSlot - 1 private val afterSlot: SlotNo = deadlineSlot + 1 private val deadline: Instant = env.slotConfig.slotToInstant(deadlineSlot) private val beforeDeadline: Instant = env.slotConfig.slotToInstant(beforeSlot) private val afterDeadline: Instant = env.slotConfig.slotToInstant(afterSlot) // Test preimages and secrets val validPreimage1: Preimage = genByteStringOfN(32).sample.get val validSecret1: Secret = sha2_256(validPreimage1) val validPreimage2: Preimage = genByteStringOfN(16).sample.get val validSecret2: Secret = sha2_256(validPreimage2) val wrongPreimage: Preimage = genByteStringOfN(12).sample.get // Bet amount private val betAmount: Coin = Coin(10_000_000L) private def createProvider(): Emulator = { // Create multiple UTXOs per player so they have funds after initiate tx val initialUtxos = Map( // Alice gets 2 UTXOs Input(genesisHash, 0) -> Output(Alice.address, Value.ada(5000)), Input(genesisHash, 1) -> Output( Alice.address, Value.ada(5000) ), // Bob gets 2 UTXOs Input(genesisHash, 2) -> Output(Bob.address, Value.ada(5000)), Input(genesisHash, 3) -> Output( Bob.address, Value.ada(5000) ), // Eve gets 1 UTXO Input(genesisHash, 4) -> Output(Eve.address, Value.ada(10000)) ) Emulator( initialUtxos = initialUtxos, initialContext = Context.testMainnet(), ) } private def getScriptContext( provider: Emulator, tx: Transaction, lotteryInput: TransactionInput ): ScriptContext = { val utxos = { val body = tx.body.value val allInputs = (body.inputs.toSet.view ++ body.collateralInputs.toSet.view ++ body.referenceInputs.toSet.view).toSet provider.findUtxos(allInputs).await().toOption.get } tx.getScriptContextV3(utxos, RedeemerPurpose.ForSpend(lotteryInput)) } private def createAndSubmitInitiateTx(provider: Emulator): (Transaction, Utxo) = { val aliceUtxos = provider.findUtxos(address = Alice.address).await().toOption.get val bobUtxos = provider.findUtxos(address = Bob.address).await().toOption.get val initiateTx = txCreator.initiateLottery( playerOneUtxos = aliceUtxos, playerTwoUtxos = bobUtxos, betAmount = betAmount, playerOnePkh = Alice.addrKeyHash, playerTwoPkh = Bob.addrKeyHash, secret1 = validSecret1, secret2 = validSecret2, revealDeadline = deadline.toEpochMilli, changeAddress = Alice.address, playerOneSigner = Alice.signer, playerTwoSigner = Bob.signer ) provider.submit(initiateTx).await() match { case Left(value) => fail(s"Couldn't submit initiate tx, error: $value") case Right(_) => () } val lotteryUtxo = initiateTx.utxos.find { case (_, txOut) => txOut.address == scriptAddress }.get (initiateTx, Utxo(lotteryUtxo)) } private def assertSuccess( provider: Emulator, tx: Transaction, lotteryInput: TransactionInput, expectedBudget: ExUnits ): Unit = { val scriptContext = getScriptContext(provider, tx, lotteryInput) val directResult = Try(LotteryContract.compiled.code(scriptContext.toData)) val evalResult = compiledContract(scriptContext.toData).program.evaluateDebug assert(evalResult.isSuccess, s"UPLC evaluation failed: ${evalResult.logs.mkString(", ")}") assert(evalResult.budget == expectedBudget) val submissionResult = provider.submit(tx).await() directResult.failed.foreach(ex => fail(s"Direct validator call failed: ${ex.getMessage}")) assert(submissionResult.isRight, s"Emulator submission failed: $submissionResult") } private def assertFailure( provider: Emulator, tx: Transaction, lotteryInput: TransactionInput, expectedError: String ): Unit = { val scriptContext = getScriptContext(provider, tx, lotteryInput) val directResult = Try(LotteryContract.compiled.code(scriptContext.toData)) val submissionResult = provider.submit(tx).await() assert(directResult.isFailure, "Direct validator call should have failed but succeeded") submissionResult match { case Left(SubmitError.ScriptFailure(message, _, _, _)) => assert( message.contains(expectedError), s"Expected error '$expectedError' but got '$message'" ) case Left(SubmitError.ValidationError(message, _)) => assert( message.contains(expectedError), s"Expected error '$expectedError' but got '$message'" ) case Left(other) => throw AssertionError(s"Expected ScriptFailure or ValidationError but got: $other") case Right(_) => throw AssertionError("Emulator submission should have failed but succeeded") } } } ``` # Example: paymentsplitter ## scalus-examples/jvm/src/main/scala/scalus/examples/paymentsplitter/OptimizedPaymentSplitterValidator.scala ```scala package scalus.examples.paymentsplitter import scalus.compiler.Compile import scalus.* import scalus.uplc.builtin.{ByteString, Data, FromData, ToData} import scalus.cardano.onchain.plutus.v1.Value.* import scalus.cardano.onchain.plutus.v1.{Credential, PubKeyHash} import scalus.cardano.onchain.plutus.v3.* import scalus.patterns.StakeValidator import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.v3.DataParameterizedValidator import scalus.cardano.onchain.plutus.prelude.List.* import scalus.cardano.onchain.plutus.prelude.Option.* /** Redeemer for the spending validator containing the own input index. * * Using the input index directly avoids iterating through all inputs to find the own input. * * @param ownInputIndex * Index of the own input in txInfo.inputs */ case class SpendRedeemer(ownInputIndex: BigInt) derives ToData, FromData /** Redeemer for the stake validator containing pre-computed split verification data. * * Off-chain code computes these values and the stake validator verifies they're correct. * * @param payeeWithChange * PubKeyHash of the payee who pays the fee and receives change * @param sumContractInputs * Total lovelace from all contract UTxOs being spent * @param splitPerPayee * Equal split amount each payee receives * @param nPayed * Number of payees being paid */ case class SplitVerificationRedeemer( payeeWithChange: PubKeyHash, sumContractInputs: BigInt, splitPerPayee: BigInt, nPayed: BigInt ) derives ToData, FromData /** Optimized Payment Splitter - Split payouts equally among a list of specified payees. * * This is an optimized implementation using the "stake validator" pattern (withdraw zero trick) to * reduce execution costs when spending multiple UTxOs. * * '''The Problem:''' In the naive [[NaivePaymentSplitterValidator]], each UTxO spend executes the * full validation logic - iterating through ALL inputs and outputs. When spending N UTxOs, this * results in O(N²) iteration cost. * * '''The Solution:''' Split the logic: * - '''Stake validator (reward endpoint):''' Runs ONCE, does all heavy computation * - '''Spending validator:''' Runs per UTxO, minimal - just checks stake validator executed * * '''How it works:''' * 1. Off-chain code computes: sumContractInputs, splitPerPayee, etc. * 2. These values are passed in the stake validator's redeemer * 3. Stake validator verifies the claimed values match actual transaction * 4. Spending validator just checks the stake validator ran (withdraw zero trick) * * @see * [[https://github.com/Anastasia-Labs/design-patterns/tree/main/stake-validator]] * @see * [[scalus.patterns.StakeValidator]] */ @Compile object OptimizedPaymentSplitterValidator extends DataParameterizedValidator { /** Spending endpoint - minimal logic. * * Just verifies that the stake validator (reward endpoint) was executed. The actual validation * happens in the reward endpoint. * * Uses the input index from the redeemer to avoid iterating through all inputs to find own * input. */ inline override def spend( payeesData: Data, datum: Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val spendRedeemer = redeemer.to[SpendRedeemer] // Get own input by index - still O(n) traversal but avoids equality comparison at each step val ownInput = tx.inputs.at(spendRedeemer.ownInputIndex) // Verify the input at the given index matches ownRef require(ownInput.outRef === ownRef, "Own input index mismatch") // Get own script hash val ownScriptHash = ownInput.resolved.address.credential.scriptOption .getOrFail("Own address must be Script") // Just check that reward endpoint was triggered (withdraw zero trick) StakeValidator.spendMinimal(ownScriptHash, tx) } /** Certifying endpoint - allows stake registration and de-registration. * * This enables the zero-withdraw trick by allowing the stake credential to be registered. The * stake address must be registered before it can be used for withdrawals. */ inline override def certify( payeesData: Data, redeemer: Data, cert: TxCert, tx: TxInfo ): Unit = { // Allow registration and de-registration for the stake validator pattern cert match case TxCert.RegStaking(_, _) => () // Allow registration case TxCert.UnRegStaking(_, _) => () // Allow de-registration case _ => fail("Only stake registration/de-registration allowed") } /** Reward endpoint - heavy computation. * * Verifies that the pre-computed values in the redeemer match the actual transaction. This * runs only ONCE per transaction, regardless of how many UTxOs are spent. */ inline override def reward( payeesData: Data, redeemer: Data, stakingKey: Credential, tx: TxInfo ): Unit = { val payees = payeesData .to[List[ByteString]] .map(payee => Credential.PubKeyCredential(PubKeyHash(payee))) val verification = redeemer.to[SplitVerificationRedeemer] val ownScriptHash = stakingKey.scriptOption.getOrFail("Staking key must be Script") val ownScriptCredential = Credential.ScriptCredential(ownScriptHash) val payeeWithChangeCredential = Credential.PubKeyCredential(verification.payeeWithChange) // Verify sumContractInputs and find fee payer val (foundFeePayer, actualSumContractInputs) = tx.inputs .foldLeft((false, BigInt(0))) { case ((foundPayer, sum), input) => val inputCredential = input.resolved.address.credential if payees.contains(inputCredential) then if inputCredential === payeeWithChangeCredential then require(!foundPayer, "Already found a fee payer") (true, sum) else fail("Payee input must be from payeeWithChange") else if inputCredential === ownScriptCredential then // Only ADA is split; reject contract inputs carrying native tokens, otherwise // the fee payer could pocket them for free (outputs reconcile lovelace only). require( input.resolved.value.withoutLovelace.isZero, "Contract input must contain only ADA" ) (foundPayer, sum + input.resolved.value.getLovelace) else fail("Input not from the contract or fee payer") } require(foundFeePayer, "Fee payer not found in inputs") require( actualSumContractInputs === verification.sumContractInputs, "sumContractInputs mismatch" ) // Verify outputs match the claimed split val (actualNPayed, sumPayeeOutputs, payeeWithChangeSum) = tx.outputs.foldLeft((BigInt(0), BigInt(0), BigInt(0))) { case ((n, sum, changeSum), output) => val outputCredential = output.address.credential val value = output.value.getLovelace outputCredential match case Credential.PubKeyCredential(pkh) => require(payees.contains(outputCredential), "Output must be to a payee") if pkh === verification.payeeWithChange then (n + 1, sum + value, changeSum + value) else // Non-change payee must receive exactly splitPerPayee require( value === verification.splitPerPayee, "Payee must receive exact split" ) (n + 1, sum + value, changeSum) case _ => fail("Output to script is not allowed") } require(actualNPayed === verification.nPayed, "nPayed mismatch") require(payees.length === actualNPayed, "Not all payees were paid") // Verify the split math: // sumContractInputs should be distributed as: // - (nPayed - 1) * splitPerPayee to non-change payees // - remainder to change payee (splitPerPayee + fee compensation) // Allow small remainder (< nPayed) for rounding val expectedMinOutput = verification.nPayed * verification.splitPerPayee val remainder = verification.sumContractInputs - expectedMinOutput require( remainder >= 0 && remainder < verification.nPayed, "value to be payed to payees is too low" ) // Change payee should have received at least splitPerPayee require( payeeWithChangeSum >= verification.splitPerPayee, "Change payee received less than split" ) } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/paymentsplitter/PaymentSplitterContract.scala ```scala package scalus.examples.paymentsplitter import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.cardano.onchain.plutus.prelude.List import scalus.uplc.PlutusV3 import scalus.uplc.builtin.{ByteString, Data} /** Naive payment splitter: split payouts equally among a fixed list of payees. */ object NaivePaymentSplitterContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(NaivePaymentSplitterValidator.validate) lazy val blueprint = Blueprint.plutusV3[List[ByteString], Unit, Unit]( title = "Naive Payment Splitter", description = "Split payouts equally among a list of specified payees (naive implementation). " + "Parameterized by the payee public key hashes; datum and redeemer are unused.", version = "1.0.0", license = Some("Apache-2.0"), // DataParameterizedValidator applies the payee list parameter as Data on the UPLC level; the // cast only re-labels the phantom type for schema derivation. compiled = compiled.asInstanceOf[PlutusV3[List[ByteString] => Data => Unit]] ) } /** Optimized payment splitter using the stake-validator (withdraw-zero) pattern. */ object OptimizedPaymentSplitterContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(OptimizedPaymentSplitterValidator.validate) lazy val blueprint = Blueprint.plutusV3[List[ByteString], Unit, SplitVerificationRedeemer]( title = "Optimized Payment Splitter", description = "Split payouts equally among a list of specified payees (optimized with the stake " + "validator pattern). Parameterized by the payee public key hashes; the split is " + "verified once in the reward endpoint.", version = "1.0.0", license = Some("Apache-2.0"), // DataParameterizedValidator applies the payee list parameter as Data on the UPLC level; the // cast only re-labels the phantom type for schema derivation. compiled = compiled.asInstanceOf[PlutusV3[List[ByteString] => Data => Unit]] ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/paymentsplitter/PaymentSplitterValidator.scala ```scala package scalus.examples.paymentsplitter import scalus.compiler.Compile import scalus.* import scalus.uplc.builtin.{ByteString, Data} import scalus.cardano.onchain.plutus.v1 import scalus.cardano.onchain.plutus.v1.Value.* import scalus.cardano.onchain.plutus.v1.{Credential, PubKeyHash} import scalus.cardano.onchain.plutus.v2.TxOut import scalus.cardano.onchain.plutus.v3.{DataParameterizedValidator, TxInfo, TxOutRef} import scalus.cardano.onchain.plutus.prelude.{AssocMap, *} import scalus.cardano.onchain.plutus.prelude.List.* import scalus.cardano.onchain.plutus.prelude.Option.* /** Naive Payment Splitter - Split payouts equally among a list of specified payees. * * This is the naive implementation where the spending validator executes the full validation logic * for each UTxO being spent. When spending N UTxOs, this results in O(N²) iteration cost because * each invocation iterates through ALL inputs and outputs. * * For an optimized version using the stake validator pattern, see * [[OptimizedPaymentSplitterValidator]]. * * A payment splitter can be used for example to create a shared project donation address, ensuring * that all payees receive the same amount * * Sending lovelace to the contract works similarly to sending lovelace to any other address. The * payout transaction can only be submitted by one of the payees, and the output addresses are * restricted to the payees. The output sum must be equally divided to ensure the transaction is * successful. * * @see * [[https://meshjs.dev/smart-contracts/payment-splitter]] */ @Compile object NaivePaymentSplitterValidator extends DataParameterizedValidator { inline override def spend( payeesData: Data, datum: Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val payees = payeesData .to[List[ByteString]] .map(payee => Credential.PubKeyCredential(PubKeyHash(payee))) val myTxInputCredential = tx.findOwnInputOrFail(ownRef).resolved.address.credential // Find the first and single payee that triggers the payout and pays the fee // and calculate the sum of contract inputs val (optPayeeInputWithChange, sumContractInputs) = tx.inputs .foldLeft(Option.empty[TxOut], BigInt(0)) { case ((optTxOut, sumContractInputs), input) => if payees.contains(input.resolved.address.credential) then if optTxOut.isEmpty then (Some(input.resolved), sumContractInputs) else fail("Already found a fee payer") else if input.resolved.address.credential === myTxInputCredential then // Only ADA is split. A contract UTxO holding native tokens would let the // fee payer pocket those tokens for free (outputs reconcile lovelace only), // so reject non-ADA contract inputs outright. require( input.resolved.value.withoutLovelace.isZero, "Contract input must contain only ADA" ) (optTxOut, sumContractInputs + input.resolved.value.getLovelace) else fail("Input not from the contract or payer") } val payeeInputWithChange = optPayeeInputWithChange.getOrFail( "Fee payer not found in inputs" ) val (sumOutput, sumsPerPayee) = tx.outputs.foldLeft( (BigInt(0), AssocMap.empty[Credential.PubKeyCredential, BigInt]) ) { case (state, output) => val (sum, sumsPerPayee) = state val value = output.value.getLovelace val payee: Credential.PubKeyCredential = output.address.credential match case Credential.PubKeyCredential(pkh) => Credential.PubKeyCredential(pkh) case _ => fail("Output to script is not allowed") sumsPerPayee.get(payee) match case None => (sum + value, sumsPerPayee.insert(payee, value)) case Some(prevSum) => (sum + value, sumsPerPayee.insert(payee, prevSum + value)) } val (optSplit, optPayeeSumWithChange, nPayed) = sumsPerPayee.toList.foldLeft( (Option.empty[BigInt], Option.empty[BigInt], BigInt(0)) ) { case ((optSplit, optPayeeSumWithChange, nPayed), (payee, value)) => require(payees.contains(payee), "Output must be to a payee") if payeeInputWithChange.address.credential === payee then (optSplit, Some(value), nPayed + 1) else optSplit match case None => (Some(value), optPayeeSumWithChange, nPayed + 1) case Some(split) => require(split === value, "Payee must receive exact split") (Some(split), optPayeeSumWithChange, nPayed + 1) } require(payees.length === nPayed, "Not all payees were paid") optSplit match case None => // one payee, no split case Some(split) => val payeeSumWithChange = optPayeeSumWithChange.getOrFail("No change output") val eqSumValue = sumOutput - payeeSumWithChange + split val reminder = sumContractInputs - eqSumValue require( reminder >= BigInt(0) && reminder < nPayed, "value to be payed to payees is too low" ) // nOutputs * (split + 1) > sumContractInputs <=> // nOutputs * split + nOutputs > sumContractInputs <=> // eqSumValue + nOutputs > sumContractInputs <=> // nOutputs > reminder ( = sumContractInputs - eqSumValue) // // max number of payers ≈ 250 (16kB / 28 bytes / 2 (inputs and outputs)) // thus, up to 250 lovelace of reminder is possible, so we can ignore it } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/paymentsplitter/NaivePaymentSplitterValidatorTest.scala ```scala package scalus.examples.paymentsplitter import org.scalatest.funsuite.AnyFunSuite import scalus.* import scalus.uplc.builtin.{ByteString, Data} import scalus.uplc.builtin.Data.toData import scalus.cardano.onchain.plutus.v1.Credential.{PubKeyCredential, ScriptCredential} import scalus.cardano.onchain.plutus.v1.{Address, PubKeyHash, Value} import scalus.cardano.onchain.plutus.v2.TxOut import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.v3.ScriptInfo.SpendingScript import scalus.cardano.onchain.plutus.prelude.{List as SList, Option as SOption, SortedMap} import scalus.cardano.ledger.ExUnits import scalus.testing.kit.ScalusTest /** Tests for NaivePaymentSplitterValidator using shared test cases. */ class NaivePaymentSplitterValidatorTest extends AnyFunSuite with ScalusTest with PaymentSplitterTestCases { private val contract = NaivePaymentSplitterContract.compiled.withErrorTraces private val lockTxId = random[TxId] private val payeesTxId = random[TxId] private val txId = random[TxId] private val scriptHash = contract.script.scriptHash private val expectedBudgets: Map[String, ExUnits] = ScalaCompilerVersion.baseline( pre38 = Map( "success when payments are correctly split for a single payee" -> ExUnits( memory = 212586, steps = 66_265072 ), "success when payments are correctly split between 2 payees" -> ExUnits( memory = 332418, steps = 104_000529 ), "success when payments are correctly split between 3 payees" -> ExUnits( memory = 471354, steps = 149_314565 ), "success when split equally and remainder compensates fee - o1" -> ExUnits( memory = 471354, steps = 149_314565 ), "success when split equally and remainder compensates fee - o2" -> ExUnits( memory = 471354, steps = 149_314565 ), "success when split equally and remainder compensates fee - o3" -> ExUnits( memory = 471354, steps = 149_314565 ), "success between 5 payees" -> ExUnits(memory = 816735, steps = 266_534967), "success with multiple contract UTxOs" -> ExUnits(memory = 581962, steps = 187_958237) ), since38 = Map( "success when payments are correctly split for a single payee" -> ExUnits( memory = 203950L, steps = 63193149L ), "success when payments are correctly split between 2 payees" -> ExUnits( memory = 321922L, steps = 100263919L ), "success when payments are correctly split between 3 payees" -> ExUnits( memory = 458998L, steps = 144913268L ), "success when split equally and remainder compensates fee - o1" -> ExUnits( memory = 458998L, steps = 144913268L ), "success when split equally and remainder compensates fee - o2" -> ExUnits( memory = 458998L, steps = 144913268L ), "success when split equally and remainder compensates fee - o3" -> ExUnits( memory = 458998L, steps = 144913268L ), "success between 5 payees" -> ExUnits(memory = 800659L, steps = 260804296L), "success with multiple contract UTxOs" -> ExUnits(memory = 569606L, steps = 183556940L) ) ) // Run all shared test cases testCases.foreach { tc => test(s"Naive: ${tc.name}") { runTestCase(tc) } } test("Naive: rejects a contract input carrying native tokens (no token skim)") { import Payee.{A, B} val payeesData = SList(A.pkh, B.pkh).toData val applied = contract.program $ payeesData // One contract UTxO holds 30 ADA *plus* a native token. The outputs split only the ADA // (A gets 23, B gets 15) and never account for the token — pre-fix the validator ignored it, // letting the fee payer skim the token. The ADA-only guard must reject this. val tokenPolicy = genByteStringOfN(28).sample.get val contractInput = TxInInfo( outRef = TxOutRef(lockTxId, 0), resolved = TxOut( address = Address(ScriptCredential(scriptHash), SOption.None), value = Value.lovelace(30) + Value(tokenPolicy, ByteString.fromString("SKIM"), 1) ) ) val feePayerInput = TxInInfo( outRef = TxOutRef(payeesTxId, 0), resolved = TxOut( address = Address(PubKeyCredential(PubKeyHash(A.pkh)), SOption.None), value = Value.lovelace(10) ) ) val outputs = SList( TxOut(Address(PubKeyCredential(PubKeyHash(A.pkh)), SOption.None), Value.lovelace(23)), TxOut(Address(PubKeyCredential(PubKeyHash(B.pkh)), SOption.None), Value.lovelace(15)) ) val txOutRef = TxOutRef(lockTxId, 0) val context = ScriptContext( txInfo = TxInfo( inputs = SList(feePayerInput, contractInput), outputs = outputs, fee = BigInt(2), redeemers = SortedMap.fromList( SList((ScriptPurpose.Spending(txOutRef), Data.unit)) ), id = txId ), redeemer = Data.unit, scriptInfo = SpendingScript(txOutRef = txOutRef) ) val result = (applied $ context.toData).evaluateDebug assert( result.isFailure, s"Expected rejection of token-bearing contract input: ${result.logs}" ) assert( result.logs.exists(_.contains("only ADA")), s"Expected 'only ADA' error, got: ${result.logs.mkString(", ")}" ) } private def runTestCase(tc: PaymentSplitterTestCase): Unit = { val payeesList = tc.payees.map(_.pkh) val payeesData = payeesList.toData val applied = contract.program $ payeesData // Build inputs val scriptInputs = tc.contractInputs.zipWithIndex.map { case (value, idx) => TxInInfo( outRef = TxOutRef(lockTxId, idx), resolved = TxOut( address = Address(ScriptCredential(scriptHash), SOption.None), value = Value.lovelace(value) ) ) } val feePayerInputs = if tc.feePayerInput._2 > 0 then scala.List( TxInInfo( outRef = TxOutRef(payeesTxId, 0), resolved = TxOut( address = Address( PubKeyCredential(PubKeyHash(tc.feePayerInput._1.pkh)), SOption.None ), value = Value.lovelace(tc.feePayerInput._2) ) ) ) else scala.List.empty val allInputs = SList.from(feePayerInputs ++ scriptInputs) // Build outputs val txOutputs = tc.outputs.map { case Output(payee, amount) => TxOut( address = Address(PubKeyCredential(PubKeyHash(payee.pkh)), SOption.None), value = Value.lovelace(amount) ) } val txOutRef = TxOutRef(lockTxId, 0) val redeemer = Data.unit val context = ScriptContext( txInfo = TxInfo( inputs = allInputs, outputs = txOutputs, fee = tc.fee, redeemers = SortedMap.fromList( SList((ScriptPurpose.Spending(txOutRef), redeemer)) ), id = txId ), redeemer = redeemer, scriptInfo = SpendingScript(txOutRef = txOutRef) ) val programWithContext = applied $ context.toData val result = programWithContext.evaluateDebug if tc.expectedSuccess then assert( result.isSuccess, clue = s"Expected success but got failure: ${result.logs.mkString(", ")}" ) expectedBudgets.get(tc.name).foreach { expected => assert(result.budget == expected, s"Budget mismatch for '${tc.name}'") } else assert( result.isFailure, clue = s"Expected failure but got success" ) assert( tc.matchesError(result.logs), clue = s"Expected error matching '${tc.expectedError.getOrElse("")}' but got: ${result.logs.mkString(", ")}" ) } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/paymentsplitter/OptimizedPaymentSplitterValidatorTest.scala ```scala package scalus.examples.paymentsplitter import org.scalatest.funsuite.AnyFunSuite import scalus.* import scalus.uplc.builtin.Data.toData import scalus.cardano.ledger.ExUnits import scalus.cardano.onchain.plutus.v1.Credential.{PubKeyCredential, ScriptCredential} import scalus.cardano.onchain.plutus.v1.{Address, Credential, PubKeyHash, Value} import scalus.cardano.onchain.plutus.v2.TxOut import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.v3.ScriptInfo.{RewardingScript, SpendingScript} import scalus.cardano.onchain.plutus.prelude.{List as SList, Option as SOption, SortedMap} import scalus.testing.kit.ScalusTest /** Tests for OptimizedPaymentSplitterValidator using shared test cases. */ class OptimizedPaymentSplitterValidatorTest extends AnyFunSuite with ScalusTest with PaymentSplitterTestCases { private val contract = OptimizedPaymentSplitterContract.compiled.withErrorTraces private val lockTxId = random[TxId] private val payeesTxId = random[TxId] private val txId = random[TxId] private val scriptHash = contract.script.scriptHash private val expectedRewardBudgets: Map[String, ExUnits] = ScalaCompilerVersion.baseline( pre38 = Map( "success when payments are correctly split for a single payee" -> ExUnits( memory = 168788, steps = 52_050090 ), "success when payments are correctly split between 2 payees" -> ExUnits( memory = 226271, steps = 70_858271 ), "success when payments are correctly split between 3 payees" -> ExUnits( memory = 286555, steps = 91_339948 ), "success when split equally and remainder compensates fee - o1" -> ExUnits( memory = 286555, steps = 91_339948 ), "success when split equally and remainder compensates fee - o2" -> ExUnits( memory = 286555, steps = 91_339948 ), "success when split equally and remainder compensates fee - o3" -> ExUnits( memory = 286555, steps = 91_339948 ), "success between 5 payees" -> ExUnits(memory = 415526, steps = 137_323790), "success with multiple contract UTxOs" -> ExUnits(memory = 389739, steps = 126_301968) ), since38 = Map( "success when payments are correctly split for a single payee" -> ExUnits( memory = 163872L, steps = 50307541L ), "success when payments are correctly split between 2 payees" -> ExUnits( memory = 221355L, steps = 69115722L ), "success when payments are correctly split between 3 payees" -> ExUnits( memory = 281639L, steps = 89597399L ), "success when split equally and remainder compensates fee - o1" -> ExUnits( memory = 281639L, steps = 89597399L ), "success when split equally and remainder compensates fee - o2" -> ExUnits( memory = 281639L, steps = 89597399L ), "success when split equally and remainder compensates fee - o3" -> ExUnits( memory = 281639L, steps = 89597399L ), "success between 5 payees" -> ExUnits(memory = 410610L, steps = 135581241L), "success with multiple contract UTxOs" -> ExUnits(memory = 384823, steps = 124_559419) ) ) private val expectedSpendBudget: ExUnits = ExUnits(memory = 47028, steps = 14_275300) // Run all shared test cases testCases.foreach { tc => test(s"Optimized: ${tc.name}") { runTestCase(tc) } } test("Optimized: budget comparison with multiple UTxOs") { val tc = testCases.find(_.name.contains("multiple contract UTxOs")).get val (rewardBudget, spendBudget) = runTestCaseWithBudget(tc) assert( rewardBudget == ScalaCompilerVersion.baseline( pre38 = ExUnits(memory = 389739, steps = 126_301968), since38 = ExUnits(memory = 384823L, steps = 124559419L) ) ) assert(spendBudget == ExUnits(memory = 47028, steps = 14_275300)) } private def runTestCase(tc: PaymentSplitterTestCase): Unit = { val payeesList = tc.payees.map(_.pkh) val payeesData = payeesList.toData val applied = contract.program $ payeesData val sumContractInputs = tc.contractInputs.sum val nPayed = tc.outputs.asScala.map(_.payee).distinct.size val verification = SplitVerificationRedeemer( payeeWithChange = PubKeyHash(tc.feePayerInput._1.pkh), sumContractInputs = sumContractInputs, splitPerPayee = tc.splitPerPayee, nPayed = BigInt(nPayed) ) // Build inputs val scriptInputs = tc.contractInputs.zipWithIndex.map { case (value, idx) => TxInInfo( outRef = TxOutRef(lockTxId, idx), resolved = TxOut( address = Address(ScriptCredential(scriptHash), SOption.None), value = Value.lovelace(value) ) ) } val feePayerInputs = if tc.feePayerInput._2 > 0 then scala.List( TxInInfo( outRef = TxOutRef(payeesTxId, 0), resolved = TxOut( address = Address( PubKeyCredential(PubKeyHash(tc.feePayerInput._1.pkh)), SOption.None ), value = Value.lovelace(tc.feePayerInput._2) ) ) ) else scala.List.empty val allInputs = SList.from(feePayerInputs ++ scriptInputs) // Build outputs val txOutputs = tc.outputs.map { case Output(payee, amount) => TxOut( address = Address(PubKeyCredential(PubKeyHash(payee.pkh)), SOption.None), value = Value.lovelace(amount) ) } // Build withdrawals (for withdraw zero trick) val withdrawals = SortedMap.fromList( SList((Credential.ScriptCredential(scriptHash), BigInt(0))) ) // Build redeemers map // First script input is at index 1 if feePayer input exists, otherwise index 0 val firstScriptInputIndex = if tc.feePayerInput._2 > 0 then 1 else 0 val spendingRedeemer = SpendRedeemer(BigInt(firstScriptInputIndex)).toData val rewardingRedeemer = verification.toData val firstScriptOutRef = TxOutRef(lockTxId, 0) val stakingCredential = Credential.ScriptCredential(scriptHash) val redeemers = SortedMap.fromList( SList( (ScriptPurpose.Spending(firstScriptOutRef), spendingRedeemer), (ScriptPurpose.Rewarding(stakingCredential), rewardingRedeemer) ) ) val txInfo = TxInfo( inputs = allInputs, outputs = txOutputs, fee = tc.fee, withdrawals = withdrawals, redeemers = redeemers, id = txId ) // Test reward endpoint (where actual validation happens) val rewardContext = ScriptContext( txInfo = txInfo, redeemer = rewardingRedeemer, scriptInfo = RewardingScript(stakingCredential) ) val rewardResult = (applied $ rewardContext.toData).evaluateDebug if tc.expectedSuccess then assert( rewardResult.isSuccess, clue = s"Expected reward success but got failure: ${rewardResult.logs.mkString(", ")}" ) // Also test spending endpoint if reward succeeded val spendContext = ScriptContext( txInfo = txInfo, redeemer = spendingRedeemer, scriptInfo = SpendingScript(txOutRef = firstScriptOutRef) ) val spendResult = (applied $ spendContext.toData).evaluateDebug assert( spendResult.isSuccess, clue = s"Expected spend success but got failure: ${spendResult.logs.mkString(", ")}" ) expectedRewardBudgets.get(tc.name).foreach { expected => assert(rewardResult.budget == expected, s"Reward budget mismatch for '${tc.name}'") } assert( spendResult.budget == expectedSpendBudget, s"Spend budget mismatch for '${tc.name}'" ) else assert( rewardResult.isFailure, clue = s"Expected failure but got success" ) assert( tc.matchesError(rewardResult.logs), clue = s"Expected error matching '${tc.expectedError.getOrElse("")}' but got: ${rewardResult.logs.mkString(", ")}" ) } private def runTestCaseWithBudget(tc: PaymentSplitterTestCase): (ExUnits, ExUnits) = { val payeesList = tc.payees.map(_.pkh) val payeesData = payeesList.toData val applied = contract.program $ payeesData val sumContractInputs = tc.contractInputs.sum val nPayed = tc.outputs.asScala.map(_.payee).distinct.size val verification = SplitVerificationRedeemer( payeeWithChange = PubKeyHash(tc.feePayerInput._1.pkh), sumContractInputs = sumContractInputs, splitPerPayee = tc.splitPerPayee, nPayed = BigInt(nPayed) ) // Build inputs val scriptInputs = tc.contractInputs.zipWithIndex.map { case (value, idx) => TxInInfo( outRef = TxOutRef(lockTxId, idx), resolved = TxOut( address = Address(ScriptCredential(scriptHash), SOption.None), value = Value.lovelace(value) ) ) } val feePayerTxIn = TxInInfo( outRef = TxOutRef(payeesTxId, 0), resolved = TxOut( address = Address(PubKeyCredential(PubKeyHash(tc.feePayerInput._1.pkh)), SOption.None), value = Value.lovelace(tc.feePayerInput._2) ) ) val allInputs = SList.from(feePayerTxIn :: scriptInputs) // Build outputs val txOutputs = tc.outputs.map { case Output(payee, amount) => TxOut( address = Address(PubKeyCredential(PubKeyHash(payee.pkh)), SOption.None), value = Value.lovelace(amount) ) } // Build withdrawals val withdrawals = SortedMap.fromList( SList((Credential.ScriptCredential(scriptHash), BigInt(0))) ) // Build redeemers // First script input is at index 1 (feePayer is at index 0) val spendingRedeemer = SpendRedeemer(BigInt(1)).toData val rewardingRedeemer = verification.toData val firstScriptOutRef = TxOutRef(lockTxId, 0) val stakingCredential = Credential.ScriptCredential(scriptHash) val redeemers = SortedMap.fromList( SList( (ScriptPurpose.Spending(firstScriptOutRef), spendingRedeemer), (ScriptPurpose.Rewarding(stakingCredential), rewardingRedeemer) ) ) val txInfo = TxInfo( inputs = allInputs, outputs = txOutputs, fee = tc.fee, withdrawals = withdrawals, redeemers = redeemers, id = txId ) // Get reward endpoint budget val rewardContext = ScriptContext( txInfo = txInfo, redeemer = rewardingRedeemer, scriptInfo = RewardingScript(stakingCredential) ) val rewardResult = (applied $ rewardContext.toData).evaluateDebug val rewardBudget = rewardResult.budget // Get spend endpoint budget val spendContext = ScriptContext( txInfo = txInfo, redeemer = spendingRedeemer, scriptInfo = SpendingScript(txOutRef = firstScriptOutRef) ) val spendResult = (applied $ spendContext.toData).evaluateDebug val spendBudget = spendResult.budget (rewardBudget, spendBudget) } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/paymentsplitter/PaymentSplitterTestCases.scala ```scala package scalus.examples.paymentsplitter import scalus.uplc.builtin.ByteString import scalus.cardano.onchain.plutus.prelude.List as SList import scalus.testing.kit.ScalusTest /** Shared test cases for PaymentSplitter validators. * * This trait provides a common set of test case definitions that can be used by both * NaivePaymentSplitterValidator and OptimizedPaymentSplitterValidator tests. */ trait PaymentSplitterTestCases { self: ScalusTest => enum Payee(val pkh: ByteString): case A extends Payee(genByteStringOfN(28).sample.get) case B extends Payee(genByteStringOfN(28).sample.get) case C extends Payee(genByteStringOfN(28).sample.get) case D extends Payee(genByteStringOfN(28).sample.get) case E extends Payee(genByteStringOfN(28).sample.get) case F extends Payee(genByteStringOfN(28).sample.get) case G extends Payee(genByteStringOfN(28).sample.get) case H extends Payee(genByteStringOfN(28).sample.get) case class Input(payee: Payee, amount: BigInt) case class Output(payee: Payee, amount: BigInt) extension (payee: Payee) inline infix def gives(amount: BigInt): Input = Input(payee, amount) inline infix def gets(amount: BigInt): Output = Output(payee, amount) /** Test case definition for payment splitter validation. * * @param payees * List of payees who should receive the split * @param contractInputs * List of amounts from contract UTxOs being spent * @param feePayerInput * The payee who pays the fee and their input amount * @param outputs * Expected outputs to payees * @param fee * Transaction fee * @param expectedSuccess * true if the test should succeed, false otherwise * @param expectedError * Expected error regex pattern if expectedSuccess is false */ case class PaymentSplitterTestCase( name: String, payees: SList[Payee], contractInputs: scala.List[BigInt], feePayerInput: (Payee, BigInt), outputs: SList[Output], fee: BigInt, expectedSuccess: Boolean, expectedError: Option[String] = None ) { def splitPerPayee: BigInt = { val total = contractInputs.sum val n = payees.asScala.size if n > 0 then total / n else total } def matchesError(logs: Seq[String]): Boolean = expectedError.forall { pattern => val regex = s"(?s).*($pattern).*".r logs.exists(log => regex.matches(log)) } } import Payee.* /** All test cases for PaymentSplitter validation */ lazy val testCases: scala.List[PaymentSplitterTestCase] = scala.List( PaymentSplitterTestCase( name = "success when payments are correctly split for a single payee", payees = SList(A), contractInputs = scala.List(BigInt(30)), feePayerInput = (A, BigInt(10)), outputs = SList(A gets 38), fee = BigInt(2), expectedSuccess = true ), PaymentSplitterTestCase( name = "success when payments are correctly split between 2 payees", payees = SList(A, B), contractInputs = scala.List(BigInt(30)), feePayerInput = (A, BigInt(10)), outputs = SList(A gets 23, B gets 15), fee = BigInt(2), expectedSuccess = true ), PaymentSplitterTestCase( name = "success when payments are correctly split between 3 payees", payees = SList(A, B, C), contractInputs = scala.List(BigInt(30)), feePayerInput = (A, BigInt(10)), outputs = SList(A gets 18, B gets 10, C gets 10), fee = BigInt(2), expectedSuccess = true ), PaymentSplitterTestCase( name = "success when split equally and remainder compensates fee - o1", payees = SList(A, B, C), contractInputs = scala.List(BigInt(31)), feePayerInput = (A, BigInt(3)), outputs = SList(A gets 12, B gets 10, C gets 10), fee = BigInt(2), expectedSuccess = true ), PaymentSplitterTestCase( name = "success when split equally and remainder compensates fee - o2", payees = SList(A, B, C), contractInputs = scala.List(BigInt(31)), feePayerInput = (A, BigInt(3)), outputs = SList(A gets 11, B gets 10, C gets 10), fee = BigInt(3), expectedSuccess = true ), PaymentSplitterTestCase( name = "success when split equally and remainder compensates fee - o3", payees = SList(A, B, C), contractInputs = scala.List(BigInt(31)), feePayerInput = (A, BigInt(3)), outputs = SList(A gets 10, B gets 10, C gets 10), fee = BigInt(4), expectedSuccess = true ), PaymentSplitterTestCase( name = "success between 5 payees", payees = SList(A, B, C, D, E), contractInputs = scala.List(BigInt(15000000)), feePayerInput = (A, BigInt(41961442)), outputs = SList( A gets (3000000 + 41115417), B gets 3000000, C gets 3000000, D gets 3000000, E gets 3000000 ), fee = BigInt(846025), expectedSuccess = true ), PaymentSplitterTestCase( name = "success with multiple contract UTxOs", payees = SList(A, B, C), contractInputs = scala.List(BigInt(30), BigInt(20), BigInt(10)), // 3 UTxOs = 60 total feePayerInput = (A, BigInt(10)), outputs = SList(A gets 28, B gets 20, C gets 20), fee = BigInt(2), expectedSuccess = true ), PaymentSplitterTestCase( name = "failure when a payee is not present in the inputs", payees = SList(A, B), contractInputs = scala.List(BigInt(30)), feePayerInput = (A, BigInt(0)), // Will be excluded from inputs outputs = SList(A gets 14, B gets 14), fee = BigInt(2), expectedSuccess = false, expectedError = Some("Fee payer not found") ), PaymentSplitterTestCase( name = "failure when a payee is not payed out (1 payee)", payees = SList(A), contractInputs = scala.List(BigInt(30)), feePayerInput = (A, BigInt(10)), outputs = SList.empty, fee = BigInt(2), expectedSuccess = false, expectedError = Some("Not all payees were paid") ), PaymentSplitterTestCase( name = "failure when one of the payees is not payed out", payees = SList(A, B), contractInputs = scala.List(BigInt(30)), feePayerInput = (A, BigInt(10)), outputs = SList(A gets 38), fee = BigInt(2), expectedSuccess = false, expectedError = Some("Not all payees were paid") ), PaymentSplitterTestCase( name = "failure when payee not in contract is to be payed", payees = SList(A, B), contractInputs = scala.List(BigInt(30)), feePayerInput = (A, BigInt(10)), outputs = SList(A gets 18, B gets 10, C gets 10), fee = BigInt(2), expectedSuccess = false, expectedError = Some("(?i)payee|split") // case-insensitive "payee" or "split" ), PaymentSplitterTestCase( name = "failure when inflated fee reduces the split payout", payees = SList(A, B, C), contractInputs = scala.List(BigInt(31)), feePayerInput = (A, BigInt(3)), outputs = SList(A gets 8, B gets 8, C gets 8), fee = BigInt(10), expectedSuccess = false, expectedError = Some("value to be payed|split") ) ) } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/paymentsplitter/PaymentSplitterTxBuilderTest.scala ```scala package scalus.examples.paymentsplitter import org.scalatest.funsuite.AnyFunSuite import scalus.* import scalus.uplc.builtin.Data import scalus.uplc.builtin.Data.toData import scalus.cardano.address.* import scalus.cardano.ledger.* import scalus.cardano.node.Emulator import scalus.cardano.txbuilder.{TwoArgumentPlutusScriptWitness, TxBuilder} import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.testing.kit.{Party, ScalusTest, TestUtil} import scalus.utils.await class PaymentSplitterTxBuilderTest extends AnyFunSuite with ScalusTest with PaymentSplitterTestCases { given CardanoInfo = CardanoInfo.mainnet case class TxResult( success: Boolean, error: Option[String], fee: Coin, exUnits: Map[RedeemerTag, Seq[ExUnits]], outputs: Seq[TransactionOutput] = Seq.empty ) { def totalExUnits(tag: RedeemerTag): ExUnits = exUnits .getOrElse(tag, Seq.empty) .foldLeft(ExUnits(0, 0))((a, e) => ExUnits(a.memory + e.memory, a.steps + e.steps)) } private val genesisHash = TestUtil.genesisHash // Scale factor to convert test case amounts to realistic lovelace values // Test cases use small numbers (30, 31, etc.) which we scale to ADA private val ScaleFactor = 1_000_000L /** Map Payee to Party - gives us consistent addresses and signing keys */ private def payeeParty(p: Payee): Party = p match case Payee.A => Party.Alice case Payee.B => Party.Bob case Payee.C => Party.Charles case Payee.D => Party.Dave case Payee.E => Party.Eve case _ => Party.Alice private def scriptAddr(scriptHash: ScriptHash) = ShelleyAddress( summon[CardanoInfo].network, ShelleyPaymentPart.Script(scriptHash), ShelleyDelegationPart.Null ) private def applyParam[A](contract: scalus.uplc.PlutusV3[A], param: Data): Script.PlutusV3 = { // Use withErrorTraces for better error messages during debugging val applied = contract.withErrorTraces.program $ param Script.PlutusV3(applied.cborByteString) } private def buildUtxos(tc: PaymentSplitterTestCase, scriptHash: ScriptHash) = { val addr = scriptAddr(scriptHash) val feePayerParty = payeeParty(tc.feePayerInput._1) // Scale contract inputs to realistic lovelace amounts val contractUtxos = tc.contractInputs.zipWithIndex.map { case (amt, idx) => TransactionInput(genesisHash, idx) -> TransactionOutput(addr, Value.lovelace(amt.toLong * ScaleFactor), Data.unit) } // Fee payer UTxO - scaled amount plus extra for fees val feePayerUtxo = TransactionInput(genesisHash, 100) -> TransactionOutput( feePayerParty.address, Value.lovelace(tc.feePayerInput._2.toLong * ScaleFactor + 100_000_000L) ) // Collateral uses fee payer's address val collateralUtxo = TransactionInput(genesisHash, 200) -> TransactionOutput(feePayerParty.address, Value.lovelace(50_000_000L)) (contractUtxos, feePayerUtxo, collateralUtxo) } private def extractExUnits(tx: Transaction): Map[RedeemerTag, Seq[ExUnits]] = { val redeemers: Seq[Redeemer] = tx.witnessSet.redeemers.toSeq.flatMap(_.value.toSeq) redeemers.groupBy(_.tag).view.mapValues(_.map(_.exUnits)).toMap } private def runNaive(tc: PaymentSplitterTestCase): TxResult = { // Use Party PKHs for the validator parameter (matches Party addresses we'll use for UTxOs) val payeePkhs: scalus.cardano.onchain.plutus.prelude.List[scalus.uplc.builtin.ByteString] = tc.payees.map(p => payeeParty(p).addrKeyHash) val paramScript = applyParam(NaivePaymentSplitterContract.compiled, payeePkhs.toData) val (contractUtxos, feePayerUtxo, collateralUtxo) = buildUtxos(tc, paramScript.scriptHash) val emulator = Emulator((contractUtxos :+ feePayerUtxo :+ collateralUtxo).toMap) val feePayerParty = payeeParty(tc.feePayerInput._1) val feePayerPayee = tc.feePayerInput._1 // Calculate actual splitPerPayee from scaled inputs (must match what validator calculates) val totalContractLovelace = tc.contractInputs.sum * ScaleFactor val nPayees = tc.payees.asScala.size val splitPerPayeeLong = (totalContractLovelace / nPayees).toLong var builder = TxBuilder(summon[CardanoInfo]) contractUtxos.foreach { case (input, output) => builder = builder.spend(Utxo(input, output), Data.unit, paramScript) } // Explicitly add fee payer's UTxO - validator requires an input from a payee builder = builder.spend(Utxo(feePayerUtxo)) // Add outputs: non-change payees get exactly splitPerPayee, change payee gets rest via changeTo tc.payees.asScala.foreach { payee => val outputAddr = payeeParty(payee).address if payee == feePayerPayee then // Fee payer output receives change - minimum is splitPerPayee builder = builder.changeTo( TransactionOutput(outputAddr, Value.lovelace(splitPerPayeeLong)) ) else // Other payees get exactly splitPerPayee builder = builder.payTo(outputAddr, Value.lovelace(splitPerPayeeLong)) } try { val tx = builder .complete(emulator, feePayerParty.address) .await() .sign(feePayerParty.signer) .transaction val result = emulator.submit(tx).await() TxResult( result.isRight, result.left.toOption.map(_.toString), tx.body.value.fee, extractExUnits(tx), tx.body.value.outputs.map(_.value).toSeq ) } catch { case e: scalus.cardano.txbuilder.TxBuilderException.BalancingException => System.err.println(s"=== SCRIPT EVALUATION FAILED ===") System.err.println(s"Error: ${e.getMessage}") System.err.println( s"Script logs: ${e.scriptLogs.getOrElse(Seq.empty).mkString("\n")}" ) throw e case e: Throwable => System.err.println(s"=== OTHER ERROR: ${e.getClass.getName} ===") System.err.println(s"Message: ${e.getMessage}") throw e } } private def runOptimized(tc: PaymentSplitterTestCase): TxResult = { import TwoArgumentPlutusScriptWitness.* // Use Party PKHs for the validator parameter (matches Party addresses we'll use for UTxOs) // AddrKeyHash is an opaque type extending ByteString, so it can be used directly val payeePkhs: scalus.cardano.onchain.plutus.prelude.List[scalus.uplc.builtin.ByteString] = tc.payees.map(p => payeeParty(p).addrKeyHash) val paramScript = applyParam(OptimizedPaymentSplitterContract.compiled, payeePkhs.toData) val (contractUtxos, feePayerUtxo, collateralUtxo) = buildUtxos(tc, paramScript.scriptHash) // Extra UTxO for stake registration transaction val stakeRegUtxo = TransactionInput(genesisHash, 300) -> TransactionOutput(payeeParty(tc.feePayerInput._1).address, Value.lovelace(10_000_000L)) val emulator = Emulator((contractUtxos :+ feePayerUtxo :+ collateralUtxo :+ stakeRegUtxo).toMap) val feePayerParty = payeeParty(tc.feePayerInput._1) val feePayerPayee = tc.feePayerInput._1 // Calculate from scaled contract inputs (must match what buildUtxos creates) val totalContractLovelace = tc.contractInputs.sum * ScaleFactor val nPayees = tc.payees.asScala.size val splitPerPayee = totalContractLovelace / nPayees val splitPerPayeeLong = splitPerPayee.toLong val verification = SplitVerificationRedeemer( PubKeyHash(feePayerParty.addrKeyHash), totalContractLovelace, splitPerPayee, BigInt(nPayees) ) val scriptStakeAddress = StakeAddress(summon[CardanoInfo].network, StakePayload.Script(paramScript.scriptHash)) // Step 1: Register stake credential in a separate transaction // The zero-withdraw trick requires the stake address to be registered first val regTx = TxBuilder(summon[CardanoInfo]) .registerStake(scriptStakeAddress, attached(paramScript, Data.unit)) .complete(emulator, feePayerParty.address) .await() .sign(feePayerParty.signer) .transaction val regResult = emulator.submit(regTx).await() assert(regResult.isRight, s"Registration tx should succeed: $regResult") // Step 2: Now do the payment split with zero-withdraw trick var builder = TxBuilder(summon[CardanoInfo]) // Use dynamic redeemer builder to compute correct input index from final transaction contractUtxos.foreach { case (input, output) => builder = builder.spend( Utxo(input, output), (tx: Transaction) => { val inputIndex = tx.body.value.inputs.toSeq.indexOf(input) SpendRedeemer(BigInt(inputIndex)).toData }, paramScript ) } // Explicitly add fee payer's UTxO - validator requires an input from a payee builder = builder.spend(Utxo(feePayerUtxo)) builder = builder.withdrawRewards( scriptStakeAddress, Coin(0), attached(paramScript, verification) ) // Add outputs: non-change payees get exactly splitPerPayee, change payee gets rest via changeTo tc.payees.asScala.foreach { payee => val outputAddr = payeeParty(payee).address if payee == feePayerPayee then // Fee payer output receives change - minimum is splitPerPayee builder = builder.changeTo( TransactionOutput(outputAddr, Value.lovelace(splitPerPayeeLong)) ) else // Other payees get exactly splitPerPayee builder = builder.payTo(outputAddr, Value.lovelace(splitPerPayeeLong)) } try { val tx = builder .complete(emulator, feePayerParty.address) .await() .sign(feePayerParty.signer) .transaction val result = emulator.submit(tx).await() TxResult( result.isRight, result.left.toOption.map(_.toString), tx.body.value.fee, extractExUnits(tx), tx.body.value.outputs.map(_.value).toSeq ) } catch { case e: scalus.cardano.txbuilder.TxBuilderException.BalancingException => System.err.println(s"=== OPTIMIZED SCRIPT EVALUATION FAILED ===") System.err.println(s"Error: ${e.getMessage}") System.err.println( s"Script logs: ${e.scriptLogs.getOrElse(Seq.empty).mkString("\n")}" ) throw e } } testCases.filter(_.expectedSuccess).foreach { tc => test(s"TxBuilder Naive: ${tc.name}") { val r = runNaive(tc) assert(r.success, s"Expected success: ${r.error.getOrElse("")}") } test(s"TxBuilder Optimized: ${tc.name}") { val r = runOptimized(tc) assert(r.success, s"Expected success: ${r.error.getOrElse("")}") } } test("TxBuilder: Cost comparison naive vs optimized (3 UTxOs)") { val tc = testCases.find(_.name.contains("multiple contract UTxOs")).get val n = runNaive(tc) val o = runOptimized(tc) val numUtxos = tc.contractInputs.size val naiveSpend = n.totalExUnits(RedeemerTag.Spend) val optReward = o.totalExUnits(RedeemerTag.Reward) val optSpend = o.totalExUnits(RedeemerTag.Spend) println(s"\n=== Cost Comparison ($numUtxos UTxOs) ===") println( f"Naive: fee=${n.fee.value}%,d spend: mem=${naiveSpend.memory}%,d cpu=${naiveSpend.steps}%,d" ) println( f"Optimized: fee=${o.fee.value}%,d reward: mem=${optReward.memory}%,d cpu=${optReward.steps}%,d" ) println(f" spend: mem=${optSpend.memory}%,d cpu=${optSpend.steps}%,d") val memSave = 100 - (optReward.memory + optSpend.memory) * 100 / naiveSpend.memory val cpuSave = 100 - (optReward.steps + optSpend.steps) * 100 / naiveSpend.steps println(f"Savings: mem=$memSave%% cpu=$cpuSave%%\n") } test("TxBuilder: verify remainder goes to fee payer when sum doesn't divide evenly") { // Test case: contractInputs = 31, 3 payees → splitPerPayee = 10, remainder = 1 val tc = testCases.find(_.name.contains("remainder compensates fee - o1")).get val totalContractLovelace = tc.contractInputs.sum * ScaleFactor // 31_000_000 val nPayees = tc.payees.asScala.size // 3 val splitPerPayee = totalContractLovelace / nPayees // 10_333_333 val remainder = totalContractLovelace % nPayees // 1 val resultNaive = runNaive(tc) val resultOptimized = runOptimized(tc) assert(resultNaive.success, s"Naive should succeed: ${resultNaive.error.getOrElse("")}") assert( resultOptimized.success, s"Optimized should succeed: ${resultOptimized.error.getOrElse("")}" ) // Find fee payer's output by address val feePayerAddr = payeeParty(tc.feePayerInput._1).address def verifyFeePayerTransactionOutput(result: TxResult, label: String): Unit = { val feePayerOutput = result.outputs.find(_.address == feePayerAddr) assert(feePayerOutput.isDefined, s"$label: Fee payer output not found") val feePayerLovelace = feePayerOutput.get.value.coin.value val minExpected = splitPerPayee + remainder // At least their share + remainder assert( feePayerLovelace >= minExpected.toLong, s"$label: Fee payer got $feePayerLovelace lovelace, expected at least $minExpected (splitPerPayee=$splitPerPayee + remainder=$remainder)" ) println( f"$label: Fee payer received $feePayerLovelace%,d lovelace (min expected: $minExpected%,d)" ) } verifyFeePayerTransactionOutput(resultNaive, "Naive") verifyFeePayerTransactionOutput(resultOptimized, "Optimized") } } ``` # Example: pricebet ## scalus-examples/jvm/src/main/scala/scalus/examples/pricebet/OracleValidator.scala ```scala package scalus.examples.pricebet import scalus.compiler.Compile import scalus.uplc.builtin.{ByteString, Data, FromData, ToData} import scalus.examples.pricebet.MintOracleRedeemer.{Burn, Mint} import scalus.cardano.onchain.plutus.v1.{PosixTime, PubKeyHash} import scalus.cardano.onchain.plutus.v2 import scalus.cardano.onchain.plutus.v3.{DataParameterizedValidator, TxInfo, TxOutRef} import scalus.cardano.onchain.plutus.prelude.* // Parameter case class OracleConfig( seedUtxo: TxOutRef, beaconPolicyId: ByteString, beaconName: ByteString, authorizedSigner: PubKeyHash ) derives FromData, ToData // Datum case class OracleState( timestamp: PosixTime, exchangeRate: Rational ) derives FromData, ToData enum MintOracleRedeemer derives FromData, ToData: case Mint case Burn enum SpendOracleRedeemer derives FromData, ToData: case Update(oracleUtxoIndex: BigInt) case Burn @Compile object OracleValidator extends DataParameterizedValidator { /** Minting policy for the oracle beacon token. Ensures exactly one beacon token is minted by * spending a specific seed UTXO. */ inline def mint( param: Data, redeemer: Data, policyId: scalus.cardano.onchain.plutus.v3.PolicyId, tx: TxInfo ): Unit = { val mintRedeemer = redeemer.to[MintOracleRedeemer] val config = param.to[OracleConfig] require(tx.isSignedBy(config.authorizedSigner), MustBeSigned) mintRedeemer match { case Mint => // Verify the seed UTXO is being spent val seedUtxoIsSpent = tx.inputs.exists(_.outRef === config.seedUtxo) require(seedUtxoIsSpent, "Must spend seed utxo to mint the beacon") // Get the minted value and sum all quantities // We expect exactly 1 token to be minted (the beacon NFT) val mintedValue = tx.mint val allMintedTokens = mintedValue.toSortedMap.toList.flatMap { case (policyId, tokens) => tokens.toList } // Verify exactly one token is minted with quantity 1 require(allMintedTokens.length === BigInt(1), "Must mint exactly one token") val (tokenName, quantity) = allMintedTokens.head require(quantity === BigInt(1), "Must mint exactly 1 beacon token") case Burn => // Verify exactly one beacon token is burned (quantity = -1) val mintedValue = tx.mint val burnedTokens = mintedValue.toSortedMap.toList.flatMap { case (policyId, tokens) => tokens.toList } // Verify exactly one token entry with quantity -1 require(burnedTokens.length === BigInt(1), "Must burn exactly one token type") val (tokenName, quantity) = burnedTokens.head require(quantity === BigInt(-1), "Must burn exactly 1 beacon token") } } /** Spending validator for oracle UTXOs. Validates oracle updates and ensures beacon token * preservation. Also allows closing the oracle when burning the beacon. */ inline def spend( param: Data, datum: Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val config = param.to[OracleConfig] val r = redeemer.to[SpendOracleRedeemer] val state = datum.getOrFail("Must have inline datum").to[OracleState] val ownInput = tx.findOwnInputOrFail(ownRef) // Verify exchange rate is non-zero state.exchangeRate.checkDenominator() require(!state.exchangeRate.isZero, "Zero rate is not allowed") // Verify authorized signer require( tx.isSignedBy(config.authorizedSigner), "Must be signed by authorized signer" ) r match { case SpendOracleRedeemer.Update(oracleUtxoIndex) => // Verify continuation output goes to same address (preserves the oracle) val continuationOutput = tx.outputs.at(oracleUtxoIndex) require( continuationOutput.address === ownInput.resolved.address, "Continuation output must be at the same script address" ) // Extract new state and verify timestamp is within validity window val newState = continuationOutput.datum match { case v2.OutputDatum.OutputDatum(d) => d.to[OracleState] case _ => fail("Continuation must have inline datum") } // Verify timestamp is within tx validity window val validRange = tx.validRange // validity range // -------------+--------------------+----------- // timestamp ^ require( validRange.isEntirelyAfter(newState.timestamp), "Oracle timestamp must be in the past relative to the tx validity interval" ) case SpendOracleRedeemer.Burn => // Verify the beacon token is being burned in this transaction val ownScriptHash = ownInput.resolved.address.credential match { case scalus.cardano.onchain.plutus.v1.Credential.ScriptCredential(hash) => hash case _ => fail("Own input must be a script") } val burnedAmount = tx.mint.quantityOf(ownScriptHash, config.beaconName) require(burnedAmount === BigInt(-1), "Must burn the beacon token") } } private inline val ZeroExchangeRateError = "Nominator and denominator must be non-zero" private inline val MustBeSigned = "Must be signed by the authorized signer" } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/pricebet/PricebetContract.scala ```scala package scalus.examples.pricebet import scalus.compiler.Options import scalus.uplc.PlutusV3 import scalus.uplc.builtin.Data.toData import scalus.uplc.* private given Options = Options.release.copy(generateErrorTraces = true) def PriceBetContract(config: PricebetConfig) = PlutusV3.compile(PricebetValidator.validate).apply(config.toData) def OracleContract(config: OracleConfig) = PlutusV3.compile(OracleValidator.validate).apply(config.toData) ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/pricebet/PricebetContracts.scala ```scala package scalus.examples.pricebet import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 import scalus.uplc.builtin.Data /** Blueprint and compiled script for the price-bet contract (parameterized, unapplied). */ object PricebetContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(PricebetValidator.validate) lazy val blueprint = Blueprint.plutusV3[PricebetConfig, PricebetState, Action]( title = "Price bet", description = "Two-party bet on a future oracle price, parameterized by a PricebetConfig. The winner " + "is determined by comparing the oracle's reported price against the agreed strike.", version = "1.0.0", license = Some("Apache-2.0"), // DataParameterizedValidator applies the PricebetConfig parameter as Data on the UPLC level; // the cast only re-labels the phantom type for schema derivation. compiled = compiled.asInstanceOf[PlutusV3[PricebetConfig => Data => Unit]] ) } /** Blueprint and compiled script for the price oracle contract (parameterized, unapplied). */ object PricebetOracleContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(OracleValidator.validate) lazy val blueprint = Blueprint.plutusV3[OracleConfig, OracleState, SpendOracleRedeemer]( title = "Price oracle", description = "Oracle that publishes a signed price feed consumed by the price-bet contract, " + "parameterized by an OracleConfig. Datum/redeemer shown for the oracle-spend path.", version = "1.0.0", license = Some("Apache-2.0"), // DataParameterizedValidator applies the OracleConfig parameter as Data on the UPLC level; // the cast only re-labels the phantom type for schema derivation. compiled = compiled.asInstanceOf[PlutusV3[OracleConfig => Data => Unit]] ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/pricebet/PricebetTransactions.scala ```scala package scalus.examples.pricebet import scalus.uplc.builtin.{ByteString, Data, FromData} import scalus.cardano.address.Address as OffchainAddress import scalus.cardano.ledger.* import scalus.cardano.txbuilder.* import scalus.cardano.onchain.plutus.v1.{PosixTime, PubKeyHash} import scalus.cardano.onchain.plutus.prelude.* import java.time.Instant /** Transaction creator for Pricebet and Oracle contracts. * * The oracle contract is parameterized with OracleConfig which includes the seed UTXO, beacon * token info, and authorized signer. This config gets baked into the script hash, making each * oracle instance unique. * * @param env * Cardano environment info * @param evaluator * Plutus script evaluator * @param oracleConfig * Configuration for the oracle (seed UTXO, beacon info, authorized signer) * @param pricebetConfig * Configuration for the pricebet (oracle script hash) */ case class PricebetTransactions( env: CardanoInfo, evaluator: PlutusScriptEvaluator, oracleConfig: OracleConfig, pricebetConfig: PricebetConfig ) { // Build the parameterized scripts private val oracleContract = OracleContract(oracleConfig) private val pricebetContract = PriceBetContract(pricebetConfig) val oracleScript: Script.PlutusV3 = oracleContract.script val oracleScriptAddress: OffchainAddress = oracleContract.address(env.network) val beaconPolicyId: ByteString = oracleScriptAddress.scriptHashOption.get val pricebetScriptAddress: OffchainAddress = pricebetContract.address(env.network) /** Mints the oracle beacon token and creates the initial oracle UTXO. * * The seed UTXO from the config will be spent to authorize the one-time mint. */ def mintBeaconAndCreateOracle( utxos: Utxos, initialTimestamp: PosixTime, initialExchangeRate: Rational, sponsor: OffchainAddress, signer: TransactionSigner ): Transaction = { val mintRedeemer = MintOracleRedeemer.Mint val datum = OracleState( timestamp = initialTimestamp, exchangeRate = initialExchangeRate ) // Create value with beacon token + min ADA val value = Value.ada(2) + Value.asset( ScriptHash.fromByteString(beaconPolicyId), AssetName(oracleConfig.beaconName), 1 ) // Find the seed UTXO in the available UTXOs val seedUtxo = utxos .find { case (input, _) => input.transactionId == oracleConfig.seedUtxo.id.hash && input.index == oracleConfig.seedUtxo.idx.toInt } .map(Utxo(_)) .getOrElse(throw IllegalStateException("Seed UTXO not found in available UTXOs")) TxBuilder(env, evaluator) .withDebugScript(oracleContract) .spend(seedUtxo) .collaterals(seedUtxo) .mint( ScriptHash.fromByteString(beaconPolicyId), Map(AssetName(oracleConfig.beaconName) -> 1L), TwoArgumentPlutusScriptWitness.attached( oracleContract.script, mintRedeemer ) ) .requireSignature(AddrKeyHash(oracleConfig.authorizedSigner.hash)) .payTo(oracleScriptAddress, value, datum) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Burns the oracle beacon token by spending the oracle UTXO. Both the spend validator (with * Burn redeemer) and mint validator (with Burn redeemer) run. */ def burnBeacon( utxos: Utxos, oracleUtxo: Utxo, sponsor: OffchainAddress, signer: TransactionSigner ): Transaction = { val spendRedeemer = SpendOracleRedeemer.Burn val mintRedeemer = MintOracleRedeemer.Burn // Find a collateral UTXO from available UTXOs val collateralUtxo = Utxo(utxos.head) TxBuilder(env, evaluator) .withDebugScript(oracleContract) .spend( oracleUtxo, spendRedeemer, oracleContract ) .collaterals(collateralUtxo) .mint( ScriptHash.fromByteString(beaconPolicyId), Map(AssetName(oracleConfig.beaconName) -> -1L), TwoArgumentPlutusScriptWitness.attached( oracleContract.script, mintRedeemer ) ) .requireSignature(AddrKeyHash(oracleConfig.authorizedSigner.hash)) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Updates oracle with new exchange rate and timestamp. */ def updateOracle( utxos: Utxos, oracleUtxo: Utxo, newTimestamp: PosixTime, newExchangeRate: Rational, sponsor: OffchainAddress, validFrom: Instant, validTo: Instant, oracleSigner: TransactionSigner, sponsorSigner: TransactionSigner ): Transaction = { val newState = OracleState( timestamp = newTimestamp, exchangeRate = newExchangeRate ) // The redeemer needs the output index where the oracle continuation will be // Since we're building the tx, we know it will be output index 0 (first payTo) val redeemer = SpendOracleRedeemer.Update(oracleUtxoIndex = BigInt(0)) TxBuilder(env, evaluator) .spend( oracleUtxo, redeemer, oracleContract ) .requireSignature(AddrKeyHash(oracleConfig.authorizedSigner.hash)) .payTo(oracleScriptAddress, oracleUtxo.output.value, newState) .validFrom(validFrom) .validTo(validTo) .complete(availableUtxos = utxos, sponsor) .sign(oracleSigner) .sign(sponsorSigner) .transaction } /** Create initial pricebet UTXO with owner's bet. */ def initiatePricebet( ownerUtxos: Utxos, betAmount: Coin, ownerPkh: AddrKeyHash, deadline: PosixTime, exchangeRate: Rational, changeAddress: OffchainAddress, signer: TransactionSigner ): Transaction = { val datum = PricebetState( owner = PubKeyHash(ownerPkh), player = Option.empty, deadline = deadline, exchangeRate = exchangeRate ) val ownerUtxo = Utxo(ownerUtxos.head) TxBuilder(env) .spend(ownerUtxo) .payTo(pricebetScriptAddress, Value.lovelace(betAmount.value), datum) .complete(availableUtxos = ownerUtxos, sponsor = changeAddress) .sign(signer) .transaction } /** Player joins by matching the bet amount. */ def join( utxos: Utxos, pricebetUtxo: Utxo, playerPkh: AddrKeyHash, sponsor: OffchainAddress, signer: TransactionSigner ): Transaction = { val redeemer = Action.Join val oldDatum = previousStateInlineDatum[PricebetState](pricebetUtxo) val betAmount = pricebetUtxo.output.value.coin.value // Construct new datum with player val newDatum = oldDatum.copy(player = Option.Some(PubKeyHash(playerPkh))) TxBuilder(env, evaluator) .spend(pricebetUtxo, redeemer, pricebetContract) .requireSignature(playerPkh) .payTo(pricebetScriptAddress, Value.lovelace(betAmount * 2), newDatum) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Player claims pot if oracle rate exceeds bet rate. */ def win( utxos: Utxos, pricebetUtxo: Utxo, oracleUtxo: Utxo, playerAddress: OffchainAddress, sponsor: OffchainAddress, validFrom: Instant, validTo: Instant, signer: TransactionSigner ): Transaction = { val datum = previousStateInlineDatum[PricebetState](pricebetUtxo) val playerPkh = datum.player.get.hash // The oracle will be the first (and only) reference input, so index 0 val redeemer = Action.Win(oracleOut = BigInt(0)) TxBuilder(env, evaluator) .references(oracleUtxo) .spend(pricebetUtxo, redeemer, pricebetContract) .requireSignature(AddrKeyHash(playerPkh)) .payTo(playerAddress, pricebetUtxo.output.value) .validFrom(validFrom) .validTo(validTo) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } /** Owner reclaims pot after deadline. */ def timeout( utxos: Utxos, pricebetUtxo: Utxo, ownerAddress: OffchainAddress, sponsor: OffchainAddress, validFrom: Instant, signer: TransactionSigner ): Transaction = { val redeemer = Action.Timeout val datum = previousStateInlineDatum[PricebetState](pricebetUtxo) val ownerPkh = datum.owner.hash TxBuilder(env, evaluator) .spend(pricebetUtxo, redeemer, pricebetContract) .requireSignature(AddrKeyHash(ownerPkh)) .payTo(ownerAddress, pricebetUtxo.output.value) .validFrom(validFrom) .complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } private def previousStateInlineDatum[A: FromData](utxo: Utxo): A = utxo.output.inlineDatum .getOrElse(throw IllegalStateException("UTxO must have inline datum")) .to[A] } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/pricebet/PricebetValidator.scala ```scala package scalus.examples.pricebet import scalus.compiler.Compile import scalus.uplc.builtin.ByteString import scalus.uplc.builtin.Data.{FromData, ToData} import scalus.uplc.builtin.Data import scalus.cardano.onchain.plutus.v1.{Credential, PosixTime, PubKeyHash} import scalus.cardano.onchain.plutus.v3.{DataParameterizedValidator, TxInInfo, TxInfo, TxOutRef} import scalus.cardano.onchain.plutus.v2 import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.prelude.Ord.> // Parameter case class PricebetConfig( oracleScriptHash: ByteString ) derives FromData, ToData /** @param owner * a party that initiates the bet * @param player * a player that has accepted the bet. If no player accepts the bet, the owner can redeem the * initial bet using [[Action.Timeout]] * @param deadline * a deadline for [[Action.Timeout]] funds redemption * @param exchangeRate * the immutable target exchange rate for the [[player]] to win. If the oracle ever returns a * rate greater than this value, the [[player]] wins. */ case class PricebetState( owner: PubKeyHash, player: Option[PubKeyHash], deadline: PosixTime, exchangeRate: Rational, ) derives FromData, ToData // Redeemer enum Action derives FromData, ToData: case Join case Win(oracleOut: BigInt) // oracle input idx case Timeout @Compile object PricebetValidator extends DataParameterizedValidator { inline def spend( param: Data, datum: Option[BuiltinData], redeemer: BuiltinData, tx: TxInfo, ownRef: TxOutRef ): Unit = { val state = datum.getOrFail("Datum must be present").to[PricebetState] val action = redeemer.to[Action] val config = param.to[PricebetConfig] val ownInput = tx.findOwnInputOrFail(ownRef) action match { case Action.Join => // Verify no player has joined yet require(state.player.isEmpty, "Player already joined") // Find continuation output val continuationOutputs = tx.outputs.filter(out => out.address === ownInput.resolved.address) require( continuationOutputs.length === BigInt(1), "Must have exactly one continuation output" ) val continuationOutput = continuationOutputs.head val initialBetAmount = ownInput.resolved.value.getLovelace // Verify continuation output has 2x the bet require( continuationOutput.value.getLovelace === initialBetAmount * 2, "Must match bet amount" ) // Verify new datum val newState = continuationOutput.datum match { case v2.OutputDatum.OutputDatum(d) => d.to[PricebetState] case _ => fail("Continuation must have inline datum") } // Find who signed and verify they're the player require(newState.player.isDefined, "Player must be set in new datum") val playerPkh = newState.player.get require(tx.isSignedBy(playerPkh), "Must be signed by player") // Verify other fields unchanged require(newState.owner === state.owner, "Owner must not change") require(newState.deadline === state.deadline, "Deadline must not change") require( // Rational has no Eq; compare by value (cross-multiplication). RationalEq.equals(newState.exchangeRate, state.exchangeRate), "Exchange rate must not change" ) case Action.Win(index) => // Verify player exists and signed require(state.player.isDefined, "No player joined yet") val playerPkh = state.player.get require(tx.isSignedBy(playerPkh), "Must be signed by player") // Verify before deadline require(!tx.validRange.isEntirelyAfter(state.deadline), "Deadline passed") val oracleInput: TxInInfo = tx.referenceInputs.at(index) oracleInput.resolved.address.credential match { case Credential.PubKeyCredential(hash) => fail(OracleInputMustBeOracleScript) case Credential.ScriptCredential(hash) => require(hash == config.oracleScriptHash, OracleInputMustBeOracleScript) } // Authenticate the oracle UTxO by its beacon NFT — being at the oracle script // address is not enough, since anyone can pay a forged datum to that address. The // beacon is a one-shot mint under the oracle's own policy (= oracleScriptHash), so // only the genuine oracle UTxO carries it. The beacon name is a fixed convention // ([[OracleBeaconName]]), so it lives in the contract rather than the datum. require( oracleInput.resolved.value .quantityOf(config.oracleScriptHash, OracleBeaconName) === BigInt(1), OracleInputMustHaveBeacon ) val oracleState = oracleInput.resolved.datum match { case v2.OutputDatum.OutputDatum(d) => d.to[scalus.examples.pricebet.OracleState] case _ => fail("Oracle must have inline datum") } // Verify oracle timestamp is within tx validity window val validRange = tx.validRange require( validRange.isEntirelyAfter(oracleState.timestamp), "Oracle timestamp must be within transaction validity range" ) val rateToBeat = state.exchangeRate require( // by way of cross multiplication oracleState.exchangeRate > rateToBeat, "Oracle rate must exceed bet rate" ) case Action.Timeout => // Verify owner signed require(tx.signatories.exists(_ === state.owner), "Must be signed by owner") // Verify deadline passed require(tx.validRange.isEntirelyAfter(state.deadline), "Deadline not reached") } } /** The oracle's beacon NFT name — a fixed convention shared with the oracle, hardcoded here * rather than carried in the datum. */ inline def OracleBeaconName: ByteString = ByteString.fromString("ORACLE") private inline val OracleInputMustBeOracleScript = "Oracle input must be locked by the oracle script" private inline val OracleInputMustHaveBeacon = "Oracle reference input must hold the beacon token" } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/pricebet/PricebetValidatorTest.scala ```scala package scalus.examples.pricebet import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.{ByteString, Data} import scalus.cardano.ledger.* import scalus.cardano.ledger.EvaluatorMode.EvaluateAndComputeCost import scalus.cardano.ledger.rules.Context import scalus.cardano.node.Emulator import scalus.cardano.txbuilder.RedeemerPurpose import scalus.cardano.onchain.plutus.v3.TxOutRef import scalus.cardano.onchain.plutus.prelude.Rational import scalus.testing.kit.Party.{Alice, Bob, Oracle} import scalus.testing.kit.TestUtil.getScriptContextV3 import scalus.testing.kit.{ScalusTest, TestUtil} import scalus.utils.await import java.time.Instant class PricebetValidatorTest extends AnyFunSuite, ScalusTest { import PricebetValidatorTest.{*, given} test("Owner initiates bet successfully") { val provider = createProvider() val txCreator = createTxCreator(provider) val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.initiatePricebet( ownerUtxos = utxos, betAmount = betAmount, ownerPkh = Alice.addrKeyHash, deadline = deadline.toEpochMilli, exchangeRate = betExchangeRate, changeAddress = Alice.address, signer = Alice.signer ) val result = provider.submit(initTx).await() assert(result.isRight, s"Failed to submit initiate tx: ${result.left}") } test("Player joins successfully") { val provider = createProvider() val txCreator = createTxCreator(provider) val (_, pricebetUtxo) = createAndSubmitInitiateTx(provider, txCreator) val utxos = provider.findUtxos(Bob.address).await().toOption.get val joinTx = txCreator.join( utxos = utxos, pricebetUtxo = pricebetUtxo, playerPkh = Bob.addrKeyHash, sponsor = Bob.address, signer = Bob.signer ) val joinResult = assertSuccess(provider, joinTx, pricebetUtxo._1) assert( // Slightly higher than before: the exchange-rate check now uses RationalEq.equals // (cross-multiplication) instead of the previous structural equalsData on Rational. joinResult.budget == (ExUnits(memory = 128662, steps = 41_698556)) ) } test("Doesn't allow double join") { val provider = createProvider() val txCreator = createTxCreator(provider) val (_, pricebetUtxo) = createAndSubmitInitiateTx(provider, txCreator) // First join succeeds val utxos1 = provider.findUtxos(Bob.address).await().toOption.get val joinTx1 = txCreator.join( utxos = utxos1, pricebetUtxo = pricebetUtxo, playerPkh = Bob.addrKeyHash, sponsor = Bob.address, signer = Bob.signer ) assertSuccess(provider, joinTx1, pricebetUtxo._1) // Find the new pricebet UTXO after join val pricebetUtxos = provider.findUtxos(txCreator.pricebetScriptAddress).await().toOption.get val newPricebetUtxo = Utxo(pricebetUtxos.head) // Second join should fail val utxos2 = provider.findUtxos(Alice.address).await().toOption.get val joinTx2 = txCreator.join( utxos = utxos2, pricebetUtxo = newPricebetUtxo, playerPkh = Alice.addrKeyHash, sponsor = Alice.address, signer = Alice.signer ) assertFailure(provider, joinTx2, newPricebetUtxo._1, "Player already joined") } test("Player wins with oracle rate above threshold") { val provider = createProvider() val txCreator = createTxCreator(provider) val oracleUtxo = createAndSubmitOracleUtxo(provider, txCreator, winningRate) val (_, pricebetUtxo) = createAndSubmitInitiateTx(provider, txCreator) // Player joins val utxos1 = provider.findUtxos(Bob.address).await().toOption.get val joinTx = txCreator.join( utxos = utxos1, pricebetUtxo = pricebetUtxo, playerPkh = Bob.addrKeyHash, sponsor = Bob.address, signer = Bob.signer ) assertSuccess(provider, joinTx, pricebetUtxo._1) // Find the pricebet UTXO after join val pricebetUtxos = provider.findUtxos(txCreator.pricebetScriptAddress).await().toOption.get val joinedPricebetUtxo = Utxo(pricebetUtxos.head) // Player wins val utxos2 = provider.findUtxos(Bob.address).await().toOption.get val winTx = txCreator.win( utxos = utxos2, pricebetUtxo = joinedPricebetUtxo, oracleUtxo = oracleUtxo, playerAddress = Bob.address, sponsor = Bob.address, validFrom = beforeDeadline, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) val winResult = assertSuccess(provider, winTx, joinedPricebetUtxo._1) assert( winResult.budget == (ExUnits(memory = 108348, steps = 33_270921)) ) } test("Win rejects a forged oracle UTxO that lacks the beacon token") { val provider = createProvider() val txCreator = createTxCreator(provider) val (_, pricebetUtxo) = createAndSubmitInitiateTx(provider, txCreator) // Bob joins val utxos1 = provider.findUtxos(Bob.address).await().toOption.get val joinTx = txCreator.join( utxos = utxos1, pricebetUtxo = pricebetUtxo, playerPkh = Bob.addrKeyHash, sponsor = Bob.address, signer = Bob.signer ) assertSuccess(provider, joinTx, pricebetUtxo._1) val joinedPricebetUtxo = { val pricebetUtxos = provider.findUtxos(txCreator.pricebetScriptAddress).await().toOption.get Utxo(pricebetUtxos.head) } // Attacker plants a fake oracle UTxO (winning rate, no beacon) and tries to win with it. val fakeOracle = createFakeOracleUtxo(provider, txCreator, winningRate) val utxos2 = provider.findUtxos(Bob.address).await().toOption.get val winTx = txCreator.win( utxos = utxos2, pricebetUtxo = joinedPricebetUtxo, oracleUtxo = fakeOracle, playerAddress = Bob.address, sponsor = Bob.address, validFrom = beforeDeadline, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) assertFailure( provider, winTx, joinedPricebetUtxo._1, "Oracle reference input must hold the beacon token" ) } test("Fails to win with a low rate") { val provider = createProvider() val txCreator = createTxCreator(provider) val oracleUtxo = createAndSubmitOracleUtxo(provider, txCreator, losingRate) val (_, pricebetUtxo) = createAndSubmitInitiateTx(provider, txCreator) // Player joins val utxos1 = provider.findUtxos(Bob.address).await().toOption.get val joinTx = txCreator.join( utxos = utxos1, pricebetUtxo = pricebetUtxo, playerPkh = Bob.addrKeyHash, sponsor = Bob.address, signer = Bob.signer ) assertSuccess(provider, joinTx, pricebetUtxo._1) // Find the pricebet UTXO after join val pricebetUtxos = provider.findUtxos(txCreator.pricebetScriptAddress).await().toOption.get val joinedPricebetUtxo = Utxo(pricebetUtxos.head) // Player tries to win but should fail val utxos2 = provider.findUtxos(Bob.address).await().toOption.get val winTx = txCreator.win( utxos = utxos2, pricebetUtxo = joinedPricebetUtxo, oracleUtxo = oracleUtxo, playerAddress = Bob.address, sponsor = Bob.address, validFrom = beforeDeadline, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) assertFailure(provider, winTx, joinedPricebetUtxo._1, "Oracle rate must exceed bet rate") } test("Owner times out after deadline") { val provider = createProvider() val txCreator = createTxCreator(provider) val (_, pricebetUtxo) = createAndSubmitInitiateTx(provider, txCreator) val utxos = provider.findUtxos(Alice.address).await().toOption.get val timeoutTx = txCreator.timeout( utxos = utxos, pricebetUtxo = pricebetUtxo, ownerAddress = Alice.address, sponsor = Alice.address, validFrom = afterDeadline, signer = Alice.signer ) provider.setSlot(afterDeadlineSlot) val timeoutResult = assertSuccess(provider, timeoutTx, pricebetUtxo._1) assert( timeoutResult.budget == (ExUnits(memory = 43739, steps = 14_700074)) ) } test("Cannot timeout before deadline") { val provider = createProvider() val txCreator = createTxCreator(provider) val (_, pricebetUtxo) = createAndSubmitInitiateTx(provider, txCreator) val utxos = provider.findUtxos(Alice.address).await().toOption.get val timeoutTx = txCreator.timeout( utxos = utxos, pricebetUtxo = pricebetUtxo, ownerAddress = Alice.address, sponsor = Alice.address, validFrom = beforeDeadline, signer = Alice.signer ) provider.setSlot(beforeSlot) assertFailure(provider, timeoutTx, pricebetUtxo._1, "Deadline not reached") } test("Oracle updates successfully") { val provider = createProvider() val txCreator = createTxCreator(provider) val oracleUtxo = createAndSubmitOracleUtxo(provider, txCreator, initialRate) val utxos = provider.findUtxos(Alice.address).await().toOption.get val updateTx = txCreator.updateOracle( utxos = utxos, oracleUtxo = oracleUtxo, newTimestamp = updateTimestamp.toEpochMilli, newExchangeRate = winningRate, sponsor = Alice.address, validFrom = updateValidFrom, validTo = updateValidTo, oracleSigner = Oracle.signer, sponsorSigner = Alice.signer ) provider.setSlot(updateSlot) val updateResult = assertSuccess(provider, updateTx, oracleUtxo._1) assert( updateResult.budget == (ExUnits(memory = 65589, steps = 24_706128)) ) } test("Oracle forbids unauthorized price updates") { val provider = createProvider() val txCreator = createTxCreator(provider) val oracleUtxo = createAndSubmitOracleUtxo(provider, txCreator, initialRate) // Build a transaction manually with Alice as required signer instead of Oracle // The script bakes in Oracle as authorizedSigner, so using Alice should fail val newState = OracleState( timestamp = updateTimestamp.toEpochMilli, exchangeRate = winningRate ) val redeemer = SpendOracleRedeemer.Update(oracleUtxoIndex = BigInt(0)) val utxos = provider.findUtxos(Bob.address).await().toOption.get import scalus.cardano.txbuilder.TxBuilder val updateTx = TxBuilder(env, evaluator) .spend( oracleUtxo, redeemer, txCreator.oracleScript ) .requireSignature(Alice.addrKeyHash) // Wrong signer - Alice instead of Oracle .payTo(txCreator.oracleScriptAddress, oracleUtxo.output.value, newState) .validFrom(updateValidFrom) .validTo(updateValidTo) .complete(availableUtxos = utxos, Bob.address) .sign(Alice.signer) // Alice signs but script requires Oracle .sign(Bob.signer) .transaction provider.setSlot(updateSlot) assertFailure(provider, updateTx, oracleUtxo._1, "Must be signed by the authorized signer") } test("Oracle discovery via beacon token") { val provider = createProvider() val txCreator = createTxCreator(provider) val oracleUtxos = provider.findUtxos(Oracle.address).await().toOption.get val createTx = txCreator.mintBeaconAndCreateOracle( utxos = oracleUtxos, initialTimestamp = oracleTimestamp.toEpochMilli, initialExchangeRate = winningRate, sponsor = Oracle.address, signer = Oracle.signer ) val result = provider.submit(createTx).await() assert(result.isRight, s"Failed to submit oracle creation: ${result.left}") // Step 2: Discover oracle by searching for beacon token (real-world flow) val beaconPolicyId = txCreator.beaconPolicyId // Search all UTXOs at oracle script address val allOracleUtxos = provider.findUtxos(txCreator.oracleScriptAddress).await().toOption.get // Filter for the one with our beacon token val discoveredOracleUtxo = allOracleUtxos.find { case (input, output) => output.value.assets.assets.exists { case (assetId, assets) => assetId == ScriptHash.fromByteString( beaconPolicyId ) && assets.size == 1 && assets.head._1 == AssetName( beaconTokenName ) } } assert(discoveredOracleUtxo.isDefined, "Should be able to find oracle by beacon token") // Step 3: Verify we can read the oracle state from discovered UTXO val (_, discoveredOutput) = discoveredOracleUtxo.get val oracleState = discoveredOutput.datumOption.get.dataOption.get.to[OracleState] assert( oracleState.exchangeRate == winningRate, "Oracle should have correct exchange rate" ) } test("Burn beacon token successfully") { val provider = createProvider() val txCreator = createTxCreator(provider, PlutusScriptEvaluator(env, EvaluateAndComputeCost)) // First create the oracle with beacon val oracleUtxo = createAndSubmitOracleUtxo(provider, txCreator, initialRate) val oracleUtxos = provider.findUtxos(Oracle.address).await().toOption.get // Now burn the beacon token by spending the oracle UTXO // Oracle has UTXOs for fees and is the authorized signer val burnTx = txCreator.burnBeacon( utxos = oracleUtxos, oracleUtxo = oracleUtxo, sponsor = Oracle.address, signer = Oracle.signer ) val result = provider.submit(burnTx).await() assert(result.isRight, s"Failed to burn beacon: ${result.left}") } test("Burn beacon fails without authorized signer") { val provider = createProvider() val txCreator = createTxCreator(provider, PlutusScriptEvaluator(env, EvaluateAndComputeCost)) // First create the oracle with beacon val oracleUtxo = createAndSubmitOracleUtxo(provider, txCreator, initialRate) // Try to burn with wrong signer (Alice instead of Oracle) import scalus.cardano.txbuilder.{TxBuilder, TwoArgumentPlutusScriptWitness} val aliceUtxos = provider.findUtxos(Alice.address).await().toOption.get val collateralUtxo = Utxo(aliceUtxos.head) val burnTx = TxBuilder(env, evaluator) .spend( oracleUtxo, SpendOracleRedeemer.Burn, txCreator.oracleScript ) .requireSignature(Alice.addrKeyHash) // Wrong signer .collaterals(collateralUtxo) .mint( ScriptHash.fromByteString(txCreator.beaconPolicyId), Map(AssetName(beaconTokenName) -> -1L), TwoArgumentPlutusScriptWitness.attached( txCreator.oracleScript, MintOracleRedeemer.Burn ) ) .complete(availableUtxos = aliceUtxos, Alice.address) .sign(Alice.signer) .transaction val result = provider.submit(burnTx).await() assert(result.isLeft, s"Expected burn to fail but it succeeded") } test("Happy path") { val provider = createProvider() val txCreator = createTxCreator(provider) // create oracle with rate = 1/2 val lowRate = Rational(BigInt(1), BigInt(2)) val oracleUtxo = createAndSubmitOracleUtxo(provider, txCreator, lowRate) // owner initiates bet with rate = 3/4 val betRate = Rational(BigInt(3), BigInt(4)) val (_, pricebetUtxo) = { val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.initiatePricebet( ownerUtxos = utxos, betAmount = betAmount, ownerPkh = Alice.addrKeyHash, deadline = deadline.toEpochMilli, exchangeRate = betRate, changeAddress = Alice.address, signer = Alice.signer ) val result = provider.submit(initTx).await() assert(result.isRight, s"Failed to submit: ${result.left}") val pricebetUtxos = provider.findUtxos(txCreator.pricebetScriptAddress).await().toOption.get (initTx.body.value.inputs.toSeq.head, Utxo(pricebetUtxos.head)) } // Bob joins and tries to win with the rate 1/2 (must fail since the pricebet has it at 3/4) val pricebetUtxoAfterJoin = { val utxos = provider.findUtxos(Bob.address).await().toOption.get val joinTx = txCreator.join( utxos = utxos, pricebetUtxo = pricebetUtxo, playerPkh = Bob.addrKeyHash, sponsor = Bob.address, signer = Bob.signer ) val result = provider.submit(joinTx).await() assert(result.isRight, s"Failed to submit join: ${result.left}") val pricebetUtxos = provider.findUtxos(txCreator.pricebetScriptAddress).await().toOption.get Utxo(pricebetUtxos.head) } val bobUtxosBeforeWin = provider.findUtxos(Bob.address).await().toOption.get val winTxLowRate = txCreator.win( utxos = bobUtxosBeforeWin, pricebetUtxo = pricebetUtxoAfterJoin, oracleUtxo = oracleUtxo, playerAddress = Bob.address, sponsor = Bob.address, validFrom = beforeDeadline, validTo = deadline, signer = Bob.signer ) provider.setSlot(beforeSlot) assertFailure( provider, winTxLowRate, pricebetUtxoAfterJoin._1, "Oracle rate must exceed bet rate" ) // Update oracle to high rate 7/8 > 3/4 val highRate = Rational(BigInt(7), BigInt(8)) val updatedOracleUtxo = { provider.setSlot(updateSlot) // Set slot for oracle update val aliceUtxos = provider.findUtxos(Alice.address).await().toOption.get val updateTx = txCreator.updateOracle( utxos = aliceUtxos, oracleUtxo = oracleUtxo, newTimestamp = updateTimestamp.toEpochMilli, newExchangeRate = highRate, sponsor = Alice.address, validFrom = updateValidFrom, validTo = updateValidTo, oracleSigner = Oracle.signer, sponsorSigner = Alice.signer ) val result = provider.submit(updateTx).await() assert(result.isRight, s"Failed to update oracle: ${result.left}") val oracleUtxos = provider.findUtxos(txCreator.oracleScriptAddress).await().toOption.get Utxo(oracleUtxos.head) } // Bob wins val bobUtxosAfterUpdate = provider.findUtxos(Bob.address).await().toOption.get val winTxHighRate = txCreator.win( utxos = bobUtxosAfterUpdate, pricebetUtxo = pricebetUtxoAfterJoin, oracleUtxo = updatedOracleUtxo, playerAddress = Bob.address, sponsor = Bob.address, validTo = deadline, signer = Bob.signer, validFrom = updateValidFrom ) provider.setSlot(beforeSlot) assertSuccess(provider, winTxHighRate, pricebetUtxoAfterJoin._1) } } object PricebetValidatorTest extends ScalusTest { given env: CardanoInfo = TestUtil.testEnvironment private val evaluator = PlutusScriptEvaluator.constMaxBudget(env) // Beacon token name for oracle (policy ID is derived from oracle script hash) private val beaconTokenName = ByteString.fromString("ORACLE") // Test parameters private val betAmount = Coin.ada(10) private val deadline = Instant.parse("2025-01-15T00:00:00Z") private val beforeDeadline = Instant.parse("2025-01-14T12:00:00Z") // Middle of day, well before deadline private val afterDeadline = Instant.parse("2025-01-15T00:00:01Z") // Oracle timestamp must be BEFORE the validity interval private val oracleTimestamp = Instant.parse("2025-01-14T10:00:00Z") // Update timestamps for oracle update tests private val updateTimestamp = Instant.parse("2025-01-14T11:00:00Z") private val updateValidFrom = Instant.parse("2025-01-14T12:00:00Z") private val updateValidTo = Instant.parse("2025-01-14T14:00:00Z") private val beforeSlot = env.slotConfig.timeToSlot(beforeDeadline.toEpochMilli).toLong private val deadlineSlot = env.slotConfig.timeToSlot(deadline.toEpochMilli).toLong private val afterDeadlineSlot = env.slotConfig.timeToSlot(afterDeadline.toEpochMilli).toLong // Use validFrom time for the slot since that's when the tx is valid private val updateSlot = env.slotConfig.timeToSlot(updateValidFrom.toEpochMilli).toLong // Exchange rates as Rational // Bet rate: 3/2 = 1.5 private val betExchangeRate = Rational(BigInt(3), BigInt(2)) // Winning rate: 8/5 = 1.6 > 1.5 private val winningRate = Rational(BigInt(8), BigInt(5)) // Losing rate: 7/5 = 1.4 < 1.5 private val losingRate = Rational(BigInt(7), BigInt(5)) // Initial rate: 1/1 = 1.0 < 1.5 private val initialRate = Rational(BigInt(1), BigInt(1)) def createProvider(): Emulator = { val genesisHash = TestUtil.genesisHash Emulator( initialUtxos = Map( Input(genesisHash, 0) -> Output( address = Alice.address, value = Value.ada(100) ), Input(genesisHash, 1) -> Output( address = Alice.address, value = Value.ada(100) ), Input(genesisHash, 2) -> Output( address = Bob.address, value = Value.ada(100) ), Input(genesisHash, 3) -> Output( address = Oracle.address, value = Value.ada(100) ), Input(genesisHash, 4) -> Output( address = Oracle.address, value = Value.ada(100) ), ), initialContext = Context.testMainnet(), mutators = Set(scalus.cardano.ledger.rules.PlutusScriptsTransactionMutator) ) } /** Creates a txCreator with configs derived from the first Oracle UTXO as seed. */ def createTxCreator( provider: Emulator, eval: PlutusScriptEvaluator = evaluator ): PricebetTransactions = { val oracleUtxos = provider.findUtxos(Oracle.address).await().toOption.get val seedUtxo = oracleUtxos.head val seedTxOutRef = TxOutRef( scalus.cardano.onchain.plutus.v3.TxId(seedUtxo._1.transactionId), seedUtxo._1.index.toLong ) val oracleConfig = OracleConfig( seedUtxo = seedTxOutRef, beaconPolicyId = ByteString.empty, // Will be set from script hash beaconName = beaconTokenName, authorizedSigner = scalus.cardano.onchain.plutus.v1.PubKeyHash(Oracle.addrKeyHash) ) val oracleContract = OracleContract(oracleConfig) val actualBeaconPolicyId = oracleContract.address(env.network).scriptHashOption.get val pricebetConfig = PricebetConfig(oracleScriptHash = actualBeaconPolicyId) PricebetTransactions( env = env, evaluator = eval, oracleConfig = oracleConfig, pricebetConfig = pricebetConfig ) } def createAndSubmitInitiateTx( provider: Emulator, txCreator: PricebetTransactions ): (TransactionInput, Utxo) = { val utxos = provider.findUtxos(Alice.address).await().toOption.get val initTx = txCreator.initiatePricebet( ownerUtxos = utxos, betAmount = betAmount, ownerPkh = Alice.addrKeyHash, deadline = deadline.toEpochMilli, exchangeRate = betExchangeRate, changeAddress = Alice.address, signer = Alice.signer ) val result = provider.submit(initTx).await() assert(result.isRight, s"Failed to submit initiate tx: ${result.left}") val pricebetUtxos = provider.findUtxos(txCreator.pricebetScriptAddress).await().toOption.get val pricebetUtxo = Utxo(pricebetUtxos.head) (pricebetUtxo._1, pricebetUtxo) } def createAndSubmitOracleUtxo( provider: Emulator, txCreator: PricebetTransactions, rate: Rational ): Utxo = { val utxos = provider.findUtxos(Oracle.address).await().toOption.get val createTx = txCreator.mintBeaconAndCreateOracle( utxos = utxos, initialTimestamp = oracleTimestamp.toEpochMilli, initialExchangeRate = rate, sponsor = Oracle.address, signer = Oracle.signer ) val result = provider.submit(createTx).await() assert(result.isRight, s"Failed to submit oracle creation: ${result.left}") // Find the oracle UTXO val oracleUtxos = provider.findUtxos(txCreator.oracleScriptAddress).await().toOption.get Utxo(oracleUtxos.head) } /** Plant a *fake* oracle UTXO at the oracle script address with an attacker-chosen rate and NO * beacon token. Anyone can pay to a script address, so this needs no oracle authorization — it * is exactly the forged-oracle attack the beacon check must defeat. */ def createFakeOracleUtxo( provider: Emulator, txCreator: PricebetTransactions, rate: Rational ): Utxo = { import scalus.cardano.txbuilder.TxBuilder val utxos = provider.findUtxos(Alice.address).await().toOption.get val fakeState = OracleState( timestamp = oracleTimestamp.toEpochMilli, exchangeRate = rate ) val tx = TxBuilder(env) .payTo(txCreator.oracleScriptAddress, Value.ada(2), fakeState) .complete(availableUtxos = utxos, Alice.address) .sign(Alice.signer) .transaction val result = provider.submit(tx).await() assert(result.isRight, s"Planting fake oracle should succeed: ${result.left}") val oracleUtxos = provider.findUtxos(txCreator.oracleScriptAddress).await().toOption.get // The fake has no beacon token (its value is bare ADA). val fake = oracleUtxos .find { case (_, out) => out.value.assets.assets.isEmpty } .getOrElse(fail("Fake oracle UTxO not found")) Utxo(fake) } def assertSuccess( provider: Emulator, tx: Transaction, scriptInput: TransactionInput ): scalus.uplc.eval.Result = { val result = runValidator(provider, tx, scriptInput) assert(result.isSuccess, s"Direct validation failed: $result") val submitResult = provider.submit(tx).await() assert( submitResult.isRight, s"Emulator submission failed: ${submitResult.left}" ) result } def assertFailure( provider: Emulator, tx: Transaction, scriptInput: TransactionInput, expectedError: String ): Unit = { // Test direct validation val result = runValidator(provider, tx, scriptInput) assert(result.isFailure, "Expected validation to fail but it succeeded") // Test via emulator val submitResult = provider.submit(tx).await() assert(submitResult.isLeft, s"Expected submission to fail but it succeeded") } def runValidator( provider: Emulator, tx: Transaction, scriptInput: TransactionInput ): scalus.uplc.eval.Result = { val utxos = { val body = tx.body.value val allInputs = (body.inputs.toSet.view ++ body.collateralInputs.toSet.view ++ body.referenceInputs.toSet.view).toSet provider.findUtxos(allInputs).await().toOption.get } val scriptContext = tx.getScriptContextV3(utxos, RedeemerPurpose.ForSpend(scriptInput)) val allResolvedPlutusScriptsMap = scalus.cardano.ledger.utils.AllResolvedScripts .allResolvedPlutusScriptsMap(tx, utxos) .toOption .get // Determine which script address we're testing val scriptHash = utxos(scriptInput).address.scriptHashOption.get val plutusScript = allResolvedPlutusScriptsMap(scriptHash) val program = scalus.uplc.Program.fromCborByteString(plutusScript.script) program.runWithDebug(scriptContext) } } ``` # Example: simpletransfer ## scalus-examples/jvm/src/main/scala/scalus/examples/simpletransfer/SimpleTransferContract.scala ```scala package scalus.examples.simpletransfer import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 object SimpleTransferContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(SimpleTransferValidator.validate) lazy val blueprint = Blueprint.plutusV3[Parties, Action]( title = "Simple Transfer contract", description = "The contract allows a user (the owner) to deposit native cryptocurrency, and another user (the recipient) to withdraw arbitrary fractions of the contract balance", version = "1.0.0", license = None, compiled = compiled ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/simpletransfer/SimpleTransferValidator.scala ```scala package scalus.examples.simpletransfer import scalus.compiler.Compile import scalus.uplc.builtin.{Data, FromData, ToData} import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.v3.Validator // Datum case class Parties( owner: PubKeyHash, recipient: PubKeyHash ) derives ToData, FromData // Redeemer enum Action derives ToData, FromData: case Deposit(amount: Value) case Withdraw(amount: Value) /** https://github.com/blockchain-unica/rosetta-smart-contracts/tree/main/contracts/simple_transfer * * Simple transfer * * The contract allows a user (the owner) to deposit native cryptocurrency, and another user (the * recipient) to withdraw arbitrary fractions of the contract balance. * * At contract creation, the owner specifies the receiver's address. * * After contract creation, the contract supports two actions: * * - deposit allows the owner to deposit an arbitrary amount of native cryptocurrency in the * contract; * - withdraw allows the receiver to withdraw any amount of the cryptocurrency deposited in the * contract. */ @Compile object SimpleTransferValidator extends Validator { inline override def spend( datum: Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val datumData = datum.getOrFail("Datum not found") val Parties(owner, recipient) = datumData.to[Parties] val contract = tx.findOwnInputOrFail(ownRef).resolved val contractAddress = contract.address.credential val contractInputs = tx.findOwnInputsByCredential(contractAddress) val contractOutputs = tx.findOwnOutputsByCredential(contractAddress) val balance = contract.value // eliminate double satisfaction by ensuring exactly one contract own input and at most one own output require(contractInputs.size === BigInt(1), "Contract should have exactly one own input") require( contractOutputs.size <= BigInt(1), "Contract should have at most one own output" ) redeemer.to[Action] match case Action.Deposit(amount) => require(tx.isSignedBy(owner), "Deposit must be signed by owner") require(amount.isPositive, "Negative amount") // eliminate double satisfaction by ensuring exactly one contract own input and one own output require( contractOutputs.size === BigInt(1), "Contract should have exactly one own output" ) val contractOutput = contractOutputs.head require( contractOutput.value === balance + amount, "Contract has received incorrect amount" ) val expectedDatum = OutputDatum.OutputDatum(datumData) require(contractOutput.datum === expectedDatum, "Output datum changed") case Action.Withdraw(withdraw) => require(tx.isSignedBy(recipient), "Withdraw must be signed by recipient") require(withdraw.isPositive, "Negative amount") if withdraw === balance then // if withdrawing all, there should be no contract output require(contractOutputs.isEmpty, "Contract own output is not empty") else if (balance - withdraw).isPositive then // eliminate double satisfaction by ensuring exactly one contract own input and one own output require( contractOutputs.size === BigInt(1), "Contract should have exactly one own output" ) val contractOutput = contractOutputs.head require( contractOutput.value === balance - withdraw, "Contract balance is incorrect" ) val expectedDatum = OutputDatum.OutputDatum(datumData) require(contractOutput.datum === expectedDatum, "Output datum changed") else fail("Withdraw exceeds balance") } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/simpletransfer/SimpleTransferValidatorTest.scala ```scala package scalus.examples.simpletransfer import org.scalacheck.Gen import org.scalatest.funsuite.AnyFunSuite import scalus.* import scalus.uplc.builtin.Data import scalus.uplc.builtin.Data.toData import scalus.cardano.ledger.ExUnits import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.testing.kit.ScalusTest class SimpleTransferValidatorTest extends AnyFunSuite with ScalusTest { val fee = 10 private val contract = SimpleTransferContract.compiled.withErrorTraces private val hash: Gen[Hash] = genByteStringOfN(28) private val scriptHash = hash.sample.get private val owner = hash.sample.get private val receiver = hash.sample.get private val datum = Parties(PubKeyHash(owner), PubKeyHash(receiver)).toData private val outputDatum = OutputDatum.OutputDatum(datum) private def deposit(amount: Value) = Action.Deposit(amount).toData private def withdraw(amount: Value) = Action.Withdraw(amount).toData test("deposit") { val ctx = context( Value.lovelace(0), deposit(Value.lovelace(1000)), List(PubKeyHash(owner)), List(makePubKeyHashInput(owner, BigInt(1000))), List( makeScriptHashOutput(scriptHash, BigInt(1000), outputDatum) ), ) val res = contract.program.runWithDebug(ctx) assert(res.isSuccess, res.logs) assert( res.budget == (ExUnits(memory = 162325, steps = 49_096934)) ) } test("deposit wrong signed") { val ctx = context( Value.lovelace(0), deposit(Value.lovelace(1000)), List(PubKeyHash(receiver)), List(makePubKeyHashInput(owner, BigInt(1000))), List( makeScriptHashOutput(scriptHash, BigInt(1000), outputDatum) ), ) val res = contract.program.runWithDebug(ctx) assert(!res.isSuccess, res.logs) } test("deposit negative") { val ctx = context( Value.lovelace(1000), deposit(Value.lovelace(-1000)), List(PubKeyHash(owner)), List(makePubKeyHashInput(owner, BigInt(1))), List( makeScriptHashOutput(scriptHash, BigInt(1), outputDatum), makePubKeyHashOutput(owner, BigInt(1000), outputDatum) ), ) val res = contract.program.runWithDebug(ctx) assert(!res.isSuccess, res.logs) assert(res.logs.find(_.contains("Negative amount")).isDefined, res.logs) } test("withdraw") { val ctx = context( Value.lovelace(1000), withdraw(Value.lovelace(500)), List(PubKeyHash(receiver)), outputs = List( makePubKeyHashOutput(receiver, BigInt(500 - fee)), makeScriptHashOutput(scriptHash, BigInt(500), outputDatum) ), ) val res = contract.program.runWithDebug(ctx) assert(res.isSuccess, res.logs) assert( res.budget == (ExUnits(memory = 273403, steps = 76_266386)) ) } test("withdraw wrong signed") { val ctx = context( Value.lovelace(1000), withdraw(Value.lovelace(500)), List(PubKeyHash(owner)), outputs = List( makePubKeyHashOutput(owner, BigInt(500 - fee)), makeScriptHashOutput(scriptHash, BigInt(500), outputDatum) ), ) val res = contract.program.runWithDebug(ctx) assert(!res.isSuccess, res.logs) } test("withdraw all") { val ctx = context( Value.lovelace(500), withdraw(Value.lovelace(500)), List(PubKeyHash(receiver)), outputs = List( makePubKeyHashOutput(receiver, BigInt(500 - fee)) ), ) val res = contract.program.runWithDebug(ctx) assert(res.isSuccess, res.logs) assert( res.budget == (ExUnits(memory = 89007, steps = 28_823147)) ) } test("withdraw more") { val ctx = context( Value.lovelace(500), withdraw(Value.lovelace(1500)), List(PubKeyHash(receiver)), outputs = List( makePubKeyHashOutput(receiver, BigInt(1500 - fee)) ), ) val res = contract.program.runWithDebug(ctx) assert(!res.isSuccess, res.logs) } test("withdraw negative") { val ctx = context( Value.lovelace(1000), withdraw(Value.lovelace(-1000)), List(PubKeyHash(receiver)), List(), List( makeScriptHashOutput(scriptHash, BigInt(2000), outputDatum), ), ) val res = contract.program.runWithDebug(ctx) assert(!res.isSuccess, res.logs) assert(res.logs.find(_.contains("Negative amount")).isDefined, res.logs) } private def context( balance: Value, redeemer: Data, signatories: List[PubKeyHash], inputs: List[TxInInfo] = List.Nil, outputs: List[TxOut] ): ScriptContext = { val ownInput = TxInInfo( outRef = random[TxOutRef], resolved = TxOut( address = Address( Credential.ScriptCredential(scriptHash), Option.None ), value = balance ) ) ScriptContext( txInfo = TxInfo( inputs = inputs.prepended(ownInput), outputs = outputs, fee = fee, signatories = signatories, id = random[TxId] ), redeemer = redeemer, scriptInfo = ScriptInfo.SpendingScript( txOutRef = ownInput.outRef, datum = Option.Some(datum) ) ) } } ``` # Example: simplewallet ## scalus-examples/jvm/src/main/scala/scalus/examples/simplewallet/SimpleWalletTransactions.scala ```scala package scalus.examples.simplewallet import scalus.cardano.address.{Address, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart} import scalus.cardano.ledger.* import scalus.cardano.txbuilder.{NativeScriptWitness, TransactionSigner, TxBuilder} /** Cardano's native "simple wallet" (rosetta `simple_wallet`). * * On EVM chains a SimpleWallet contract holds funds, queues transactions, and authorizes * withdrawals. On Cardano a plain pubkey address covers all of this out of the box: the owner's * signature authorizes every spend, transactions are built and submitted directly (no on-chain * queue needed), and the entire balance can be withdrawn at any time by spending every UTxO at the * address. No Plutus contract is required. */ case class SimpleWalletTransactions(env: CardanoInfo) { /** Pay `amount` to `recipient`, returning change to `owner`. This is the EVM * `createTransaction` + `executeTransaction` pair collapsed into a single step — the * transaction is fully specified off-chain and submitted directly. */ def transfer( ownerUtxos: Utxos, recipient: Address, amount: Coin, owner: Address, signer: TransactionSigner ): Transaction = TxBuilder(env) .payTo(recipient, Value(amount)) .complete(availableUtxos = ownerUtxos, owner) .sign(signer) .transaction /** Withdraw the whole balance: spend every owner UTxO and send it all to `recipient`. The * owner's signature is the only authorization — there is no contract withdrawal function. */ def withdrawAll( ownerUtxos: Utxos, recipient: Address, signer: TransactionSigner ): Transaction = ownerUtxos .foldLeft(TxBuilder(env)) { case (builder, entry) => builder.spend(Utxo(entry)) } .complete(availableUtxos = ownerUtxos, recipient) .sign(signer) .transaction } /** Going beyond the spec: an m-of-n multisig wallet using a Cardano native script (no Plutus). * * A [[Timelock.MOf]] script defines the spending policy; any `required` of the `owners` must sign * to authorize a transaction. The wallet address is derived from the script hash, so spending is * just signature checking — no on-chain execution. */ case class MultiSigWallet(env: CardanoInfo, owners: IndexedSeq[AddrKeyHash], required: Int) { /** The m-of-n native script: any `required` of the `owners` must sign. */ val policy: Script.Native = Script.Native(Timelock.MOf(required, owners.map(Timelock.Signature(_)))) /** Wallet address derived from the native-script hash. */ val address: Address = ShelleyAddress( env.network, ShelleyPaymentPart.Script(policy.scriptHash), ShelleyDelegationPart.Null ) /** Spend `walletUtxo`, paying `amount` to `recipient` with change back to the wallet. Requires * the native-script witness plus the supplied signers, which together must cover `required` of * the owners. */ def transfer( walletUtxo: Utxo, recipient: Address, amount: Coin, requiredSigners: Set[AddrKeyHash], signers: Seq[TransactionSigner] ): Transaction = { val completed = requiredSigners .foldLeft( TxBuilder(env) .spend(walletUtxo, NativeScriptWitness.attached(policy)) .payTo(recipient, Value(amount)) )((builder, owner) => builder.requireSignature(owner)) .complete(availableUtxos = Map(walletUtxo.input -> walletUtxo.output), address) signers.foldLeft(completed)((tx, signer) => tx.sign(signer)).transaction } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/simplewallet/SimpleWalletTest.scala ```scala package scalus.examples.simplewallet import org.scalatest.funsuite.AnyFunSuite import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.Context import scalus.cardano.node.Emulator import scalus.testing.kit.Party.{Alice, Bob, Charles, Dave} import scalus.testing.kit.ScalusTest import scalus.testing.kit.TestUtil.{genesisHash, testEnvironment} import scalus.utils.await class SimpleWalletTest extends AnyFunSuite, ScalusTest { private given env: CardanoInfo = testEnvironment private val wallet = SimpleWalletTransactions(env) private def aliceProvider(): Emulator = Emulator( initialUtxos = Map( Input(genesisHash, 0) -> TransactionOutput .Babbage(Alice.address, Value.ada(10)), Input(genesisHash, 1) -> TransactionOutput .Babbage(Alice.address, Value.ada(10)) ), initialContext = Context.testMainnet() ) test("transfer pays the recipient and returns change to the owner") { val p = aliceProvider() val utxos = p.findUtxos(Alice.address).await().toOption.get val tx = wallet.transfer( ownerUtxos = utxos, recipient = Bob.address, amount = Coin(3_000_000L), owner = Alice.address, signer = Alice.signer ) assert(p.submit(tx).await().isRight, "transfer should submit") assert( tx.utxos.exists { case (_, o) => o.address == Bob.address && o.value.coin.value == 3_000_000L }, "Bob must receive 3 ADA" ) } test("withdrawAll spends every owner UTxO and sends the balance to the recipient") { val p = aliceProvider() val utxos = p.findUtxos(Alice.address).await().toOption.get val tx = wallet.withdrawAll(ownerUtxos = utxos, recipient = Bob.address, signer = Alice.signer) assert(p.submit(tx).await().isRight, "withdrawAll should submit") // Every owner UTxO is consumed and nothing is left at Alice's address. assert(utxos.keySet.forall(in => tx.body.value.inputs.toSeq.contains(in))) assert(p.findUtxos(Alice.address).await().toOption.get.isEmpty, "owner should be emptied") } test("2-of-3 multisig: any two owners can spend") { val multisig = MultiSigWallet( env, owners = IndexedSeq(Alice.addrKeyHash, Bob.addrKeyHash, Charles.addrKeyHash), required = 2 ) val walletInput = Input(genesisHash, 0) val p = Emulator( initialUtxos = Map( walletInput -> TransactionOutput.Babbage(multisig.address, Value.ada(10)) ), initialContext = Context.testMainnet() ) val walletUtxo = p.findUtxos(multisig.address).await().toOption.get.head // Alice + Bob sign (2 of 3) — accepted. val ok = multisig.transfer( walletUtxo = Utxo(walletUtxo), recipient = Dave.address, amount = Coin(3_000_000L), requiredSigners = Set(Alice.addrKeyHash, Bob.addrKeyHash), signers = Seq(Alice.signer, Bob.signer) ) assert(p.submit(ok).await().isRight, "two owners should be able to spend") // Only Alice signs (1 of 3) — rejected by the native script. val p2 = Emulator( initialUtxos = Map( walletInput -> TransactionOutput.Babbage(multisig.address, Value.ada(10)) ), initialContext = Context.testMainnet() ) val walletUtxo2 = p2.findUtxos(multisig.address).await().toOption.get.head val tooFew = multisig.transfer( walletUtxo = Utxo(walletUtxo2), recipient = Dave.address, amount = Coin(3_000_000L), requiredSigners = Set(Alice.addrKeyHash), signers = Seq(Alice.signer) ) assert(p2.submit(tooFew).await().isLeft, "one owner must not be enough") } } ``` # Example: storage ## scalus-examples/jvm/src/main/scala/scalus/examples/storage/StorageTransactions.scala ```scala package scalus.examples.storage import scalus.uplc.builtin.{ByteString, Data} import scalus.cardano.address.Address import scalus.cardano.ledger.* import scalus.cardano.txbuilder.* import scalus.patterns.Element import scalus.examples.linkedlist.{LinkedListContract, LinkedListOffchain} import scalus.cardano.onchain.plutus.prelude.Option as OnchainOption /** Transaction creator for uncapped on-chain data storage. * * Data larger than [[chunkSize]] bytes is split across multiple linked-list nodes, each submitted * as a separate transaction. All nodes live at the same script address; read them back in order * with [[readData]]. */ case class StorageTransactions( env: CardanoInfo, evaluator: PlutusScriptEvaluator, rootKey: ByteString, prefix: ByteString, chunkSize: Int ): private val ll = LinkedListOffchain( env = env, evaluator = evaluator, mintingContract = LinkedListContract.compiled, rootKey = rootKey, prefix = prefix ) val policyId: PolicyId = ll.policyId val scriptAddress: Address = ll.scriptAddress /** Build all transactions needed to store `data`. * * Transactions must be submitted sequentially in the returned order. * @return * `[initTx]` for single-chunk data, or `[initTx, appendTx1, ...]` for multi-chunk. */ def storeData( data: ByteString, userUtxos: Utxos, sponsor: Address, signer: TransactionSigner ): List[Transaction] = { val chunks = splitIntoChunks(data) val initTx = ll.init( utxos = userUtxos, rootData = Data.B(chunks.head), sponsor = sponsor, signer = signer ) if chunks.length == 1 then List(initTx) else val appendTxs = buildAppendTransactions(chunks.tail, initTx, userUtxos, sponsor, signer) initTx :: appendTxs } /** Reconstruct data from storage UTxOs by following the linked-list chain. */ def readData(utxos: Iterable[Utxo]): ByteString = ll.readAll(utxos) .map { case (_, d) => d match case Data.B(bytes) => bytes case _ => throw new IllegalStateException("Expected ByteString data in storage node") } .foldLeft(ByteString.empty)(_ ++ _) private def splitIntoChunks(data: ByteString): List[ByteString] = val bytes = data.bytes if bytes.isEmpty then List(ByteString.empty) else bytes.grouped(chunkSize).map(ByteString.fromArray).toList private def buildAppendTransactions( chunks: List[ByteString], previousTx: Transaction, userUtxos: Utxos, sponsor: Address, signer: TransactionSigner ): List[Transaction] = chunks.zipWithIndex .foldLeft((List.empty[Transaction], previousTx)) { case ((txs, prevTx), (chunk, index)) => val availableUtxos = userUtxos ++ prevTx.utxos val tailUtxo = findTailUtxo(prevTx) // Key: minimal big-endian encoding of the 1-based index (BigInt.toByteArray is // minimal-width, not fixed 4 bytes), unique per chunk val chunkKey = ByteString.fromArray(BigInt(index + 1).toByteArray) val newTx = ll.appendUnordered( utxos = availableUtxos, anchorUtxo = tailUtxo, newKey = chunkKey, nodeData = Data.B(chunk), sponsor = sponsor, signer = signer ) (txs :+ newTx, newTx) } ._1 private def findTailUtxo(tx: Transaction): Utxo = tx.utxos .find { case (_, output) => output.value.assets.assets.exists { case (cs, _) => cs == policyId } && output.inlineDatum.exists(datum => datum.to[Element].link == OnchainOption.None) } .map(Utxo.apply) .getOrElse(throw new IllegalStateException("Tail UTxO not found in transaction")) ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/storage/StorageTest.scala ```scala package scalus.examples.storage import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.ByteString.hex import scalus.uplc.builtin.{ByteString, Data} import scalus.cardano.ledger.* import scalus.cardano.ledger.EvaluatorMode import scalus.cardano.ledger.rules.{Context, PlutusScriptsTransactionMutator} import scalus.cardano.node.Emulator import scalus.patterns.{Element, ElementData} import scalus.testing.kit.Party.Alice import scalus.testing.kit.{ScalusTest, TestUtil} import scalus.uplc.* import scalus.utils.await class StorageTest extends AnyFunSuite, ScalusTest: given env: CardanoInfo = TestUtil.testEnvironment given ec: scala.concurrent.ExecutionContext = scala.concurrent.ExecutionContext.global val evaluator = PlutusScriptEvaluator(env, EvaluatorMode.EvaluateAndComputeCost) val rootKey = hex"deadbeef" val prefix = hex"4c414e" // "LAN" def makeCreator(chunkSize: Int = 1000) = StorageTransactions( env = env, evaluator = evaluator, rootKey = rootKey, prefix = prefix, chunkSize = chunkSize ) private def makeProvider(): Emulator = Emulator( initialUtxos = Map( Input(TestUtil.genesisHash, 0) -> Output(Alice.address, Value.ada(5000)), Input(TestUtil.genesisHash, 1) -> Output(Alice.address, Value.ada(5000)) ), initialContext = Context.testMainnet().copy(evaluatorMode = EvaluatorMode.EvaluateAndComputeCost), mutators = Set(PlutusScriptsTransactionMutator) ) test("store data in single chunk"): val data = ByteString.fromString("Hello, Cardano!") val creator = makeCreator() val provider = makeProvider() val userUtxos = provider.findUtxos(Alice.address).await().toOption.get val txs = creator.storeData( data = data, userUtxos = userUtxos, sponsor = Alice.address, signer = Alice.signer ) assert(txs.length == 1, s"Expected 1 transaction, got ${txs.length}") txs.foreach { tx => provider.submit(tx).await() match case Left(error) => fail(s"Failed to submit: $error") case Right(_) => () } val storageUtxos = provider.findUtxos(creator.scriptAddress).await().toOption.get assert( storageUtxos.size == 1, s"Expected 1 UTxO (root with data), got ${storageUtxos.size}" ) val root = storageUtxos.values.head.inlineDatum.get.to[Element] val rootData = root.data match case ElementData.Root(Data.B(bytes)) => bytes case _ => fail("Expected Root with ByteString data") assert(rootData == data) test("store data across 3 chunks"): val data = ByteString.fromArray(Array.fill(250)(0x42.toByte)) val creator = makeCreator(chunkSize = 100) val provider = makeProvider() val userUtxos = provider.findUtxos(Alice.address).await().toOption.get val txs = creator.storeData( data = data, userUtxos = userUtxos, sponsor = Alice.address, signer = Alice.signer ) assert(txs.length == 3, s"Expected 3 transactions, got ${txs.length}") txs.zipWithIndex.foreach { case (tx, idx) => provider.submit(tx).await() match case Left(error) => fail(s"Failed to submit transaction ${idx + 1}: $error") case Right(_) => () } val storageUtxos = provider.findUtxos(creator.scriptAddress).await().toOption.get assert( storageUtxos.size == 3, s"Expected 3 UTxOs (root + 2 nodes), got ${storageUtxos.size}" ) val reconstructed = creator.readData(storageUtxos.map(Utxo.apply)) assert(reconstructed == data, "Reconstructed data doesn't match original") ``` # Example: upgradeableproxy ## scalus-examples/jvm/src/main/scala/scalus/examples/upgradeableproxy/UpgradeableProxyContract.scala ```scala package scalus.examples.upgradeableproxy import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 object UpgradeableProxyContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(ProxyValidator.validate) lazy val blueprint = Blueprint.plutusV3[ProxyDatum, ProxyRedeemer]( title = "Upgradeable proxy validator", description = "Delegates validation to an upgradeable logic stake validator; the owner can repoint it to new logic.", version = "1.0.0", license = Some("Apache License Version 2.0"), compiled = compiled ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/upgradeableproxy/UpgradeableProxyTransactions.scala ```scala package scalus.examples.upgradeableproxy import scalus.* import scalus.cardano.address.{Address, StakeAddress} import scalus.cardano.ledger.* import scalus.cardano.txbuilder.* import scalus.uplc.PlutusV3 import scalus.uplc.builtin.Data case class ProxyTransactions( env: CardanoInfo, evaluator: PlutusScriptEvaluator, contract: PlutusV3[Data => Unit] ) { val script: Script.PlutusV3 = contract.script val scriptAddress: Address = contract.address(env.network) /** Creates the proxy UTxO at the script address with an inline datum pointing to `logicHash`. */ def deploy( utxos: Utxos, value: Value, logicHash: ScriptHash, owner: AddrKeyHash, sponsor: Address, signer: TransactionSigner ): Transaction = { val datum = ProxyDatum( logicHash = logicHash, owner = scalus.cardano.onchain.plutus.v3.PubKeyHash(owner) ) TxBuilder(env) .payTo(scriptAddress, value, datum) .complete(availableUtxos = utxos, sponsor = sponsor) .sign(signer) .transaction } /** Invokes the proxy by withdrawing from `logicStakeAddress`, triggering the logic script. */ def call( utxos: Utxos, proxyUtxo: Utxo, logicStakeAddress: StakeAddress, logicWitness: ScriptWitness, sponsor: Address, signer: TransactionSigner ): Transaction = { val datum = proxyUtxo.output.requireInlineDatum TxBuilder(env, evaluator) .spend(proxyUtxo, ProxyRedeemer.Call, script) .payTo(scriptAddress, proxyUtxo.output.value, datum) .withdrawRewards(logicStakeAddress, Coin.zero, logicWitness) .complete(availableUtxos = utxos, sponsor = sponsor) .sign(signer) .transaction } /** Upgrades the proxy to a new logic stake validator; must be signed by `ownerPkh`. */ def upgrade( utxos: Utxos, proxyUtxo: Utxo, newLogicHash: ScriptHash, ownerPkh: AddrKeyHash, sponsor: Address, signer: TransactionSigner ): Transaction = { val oldDatum = proxyUtxo.output.requireInlineDatum.to[ProxyDatum] val newDatum = oldDatum.copy(logicHash = newLogicHash) TxBuilder(env, evaluator) .spend(proxyUtxo, ProxyRedeemer.Upgrade(newLogicHash), script) .requireSignature(ownerPkh) .payTo(scriptAddress, proxyUtxo.output.value, newDatum) .complete(availableUtxos = utxos, sponsor = sponsor) .sign(signer) .transaction } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/upgradeableproxy/UpgradeableProxyValidator.scala ```scala package scalus.examples.upgradeableproxy import scalus.compiler.Compile import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.v1.{Credential, PubKeyHash} import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.uplc.builtin.{Data, FromData, ToData} /** Upgradeable proxy pattern for Cardano smart contracts. * * The pattern works by having a spend validator ensure that a stake validator has been called by * checking that * - a withdrawal has been made; * - the withdrawal has been made from a known trusted script address. * * A combination of these conditions ensure that the validator has been called, thus allowing a * forced script composition. * * This also ensures that the logic can be changed (upgraded), by updating the known trusted script * address, that is stored in the datum. In this illustration, a proxy has an owner, which can * update the datum. In a real application, a more sophisticated approach should be preferred, to * ensure trustlessness. */ case class ProxyDatum(logicHash: ValidatorHash, owner: PubKeyHash) derives FromData, ToData enum ProxyRedeemer derives FromData, ToData: case Call case Upgrade(newLogicHash: ValidatorHash) /** Spending validator for the upgradeable proxy. * * Stores the active logic script hash in the datum. Two actions: * * - `Call` - verifies the logic stake script was withdrawn and the datum is unchanged. * - `Upgrade` - owner replaces the logic script hash; value must be preserved. */ @Compile object ProxyValidator extends Validator { inline override def spend( datum: Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val d = datum.getOrFail(MissingDatum).to[ProxyDatum] val r = redeemer.to[ProxyRedeemer] val ownInput = tx.findOwnInputOrFail(ownRef) // Reject spending more than one proxy UTxO at once: otherwise a single continuation // output could satisfy several script inputs (double satisfaction) and the value of the // extra inputs would be swept off to the attacker. require( tx.findOwnInputsByCredential(ownInput.resolved.address.credential).length === BigInt(1), MultipleProxyInputs ) val continuationOutput = tx.outputs .filter(out => out.address === ownInput.resolved.address) .headOption .getOrFail(MissingContinuation) val continuationDatum = continuationOutput.datum match case OutputDatum.OutputDatum(d) => d.to[ProxyDatum] case _ => fail(ContinuationMustHaveInlineDatum) require( continuationOutput.value === ownInput.resolved.value, ValueMustBePreserved ) r match case ProxyRedeemer.Call => // Ensure the logic stake validator was called val logicCredential = Credential.ScriptCredential(d.logicHash) tx.withdrawals.getOrFail(logicCredential, LogicNotInvoked) // Ensure the proxy UTxO continues with the same datum (state preserved) require(continuationDatum.logicHash === d.logicHash, LogicHashChanged) require(continuationDatum.owner === d.owner, OwnerChanged) case ProxyRedeemer.Upgrade(newLogicHash) => // Only the owner can upgrade the logic require(tx.isSignedBy(d.owner), NotSignedByOwner) // Continuation output must carry the updated datum require(continuationDatum.logicHash === newLogicHash, LogicHashMismatch) require(continuationDatum.owner === d.owner, OwnerChanged) } inline val MissingDatum = "Proxy datum must be present" inline val MultipleProxyInputs = "Only one proxy input may be spent per transaction" inline val LogicNotInvoked = "Logic stake validator must be invoked in this transaction" inline val MissingContinuation = "Proxy continuation output not found" inline val ContinuationMustHaveInlineDatum = "Continuation output must have an inline datum" inline val LogicHashChanged = "Logic hash must not change on Call" inline val OwnerChanged = "Owner must not change" inline val NotSignedByOwner = "Transaction must be signed by the proxy owner" inline val LogicHashMismatch = "Continuation datum logic hash does not match upgrade target" inline val ValueMustBePreserved = "Proxy value must be preserved" } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/upgradeableproxy/UpgradeableProxyTest.scala ```scala package scalus.examples.upgradeableproxy import org.scalatest.funsuite.AnyFunSuite import scalus.* import scalus.cardano.address.{StakeAddress, StakePayload} import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.{Context, DefaultMutators} import scalus.cardano.node.{Emulator, NodeSubmitError} import scalus.cardano.txbuilder.{ScriptWitness, TwoArgumentPlutusScriptWitness, TxBuilder} import scalus.compiler.Options import scalus.testing.kit.Party.{Alice, Bob} import scalus.testing.kit.{ScalusTest, TestUtil} import scalus.uplc.PlutusV3 import scalus.uplc.builtin.Data import scalus.utils.await import scalus.cardano.onchain.plutus.v3.{ScriptContext, Value as OnchainValue} import scalus.cardano.onchain.plutus.prelude.{!==, ===, require} class UpgradeableProxyTest extends AnyFunSuite, ScalusTest { private given env: CardanoInfo = TestUtil.testEnvironment private given Options = Options.release private val contract = UpgradeableProxyContract.compiled.withErrorTraces // Succeeds when the transaction mints nothing. private val mustNotMintLogic = PlutusV3 .compile((scData: Data) => { val sc = scData.to[ScriptContext] require(sc.txInfo.mint === OnchainValue.zero, "Transaction must not mint") }) .withErrorTraces // Succeeds when the transaction mints something. private val mustMintLogic = PlutusV3 .compile((scData: Data) => { val sc = scData.to[ScriptContext] require(sc.txInfo.mint !== OnchainValue.zero, "Transaction must mint") }) .withErrorTraces private val mustNotMintHash = mustNotMintLogic.script.scriptHash private val mustMintHash = mustMintLogic.script.scriptHash private val mustNotMintStakeAddress = StakeAddress(env.network, StakePayload.Script(mustNotMintHash)) private val mustMintStakeAddress = StakeAddress(env.network, StakePayload.Script(mustMintHash)) private val mustNotMintWitness = TwoArgumentPlutusScriptWitness.attached(mustNotMintLogic.script, Data.unit) private val mustMintWitness = TwoArgumentPlutusScriptWitness.attached(mustMintLogic.script, Data.unit) private val proxyValue = Value.ada(10) // Always-succeeds script to mint tokens, register script addresses, etc. private val alwaysSucceeds = PlutusV3.alwaysOk.withErrorTraces private val alwaysSucceedsWitness = TwoArgumentPlutusScriptWitness.attached(alwaysSucceeds.script, Data.unit) private val alwaysSucceedsStakeAddress = StakeAddress(env.network, StakePayload.Script(alwaysSucceeds.script.scriptHash)) private val mintAssets: Map[AssetName, Long] = Map(AssetName.fromString("test") -> 1L) // One third to make sure that up to 3 scripts can fit. private val evaluator: PlutusScriptEvaluator = PlutusScriptEvaluator.const( ExUnits( env.protocolParams.maxTxExecutionUnits.memory / 3, env.protocolParams.maxTxExecutionUnits.steps / 3 ) ) private val txCreator: ProxyTransactions = ProxyTransactions(env = env, evaluator = evaluator, contract = contract) private def createProvider(): Emulator = Emulator( initialUtxos = Map( Input(TestUtil.genesisHash, 0) -> Output(Alice.address, Value.ada(5000)), Input(TestUtil.genesisHash, 1) -> Output(Alice.address, Value.ada(5000)), Input(TestUtil.genesisHash, 2) -> Output(Bob.address, Value.ada(5000)) ), initialContext = Context.testMainnet(), mutators = DefaultMutators.all ) private def assertSubmitScriptFail(provider: Emulator, expectedError: String)( tx: Transaction ): Unit = { val result = provider.submit(tx).await() result match case Right(_) => fail(s"Transaction submission should have failed but succeeded") case Left(sf: NodeSubmitError.ScriptFailure) => if !sf.logs.exists(_.contains(expectedError)) then fail( s"Expected error containing '$expectedError' but got: ${sf.logs.mkString(", ")}" ) case Left(other) => fail(s"Expected ScriptFailure but got: $other") } private def deployProxy(provider: Emulator, logicHash: ScriptHash): Utxo = { val utxos = provider.findUtxos(Alice.address).await().toOption.get val tx = txCreator.deploy( utxos = utxos, value = proxyValue, logicHash = logicHash, owner = Alice.addrKeyHash, sponsor = Alice.address, signer = Alice.signer ) assert(provider.submit(tx).await().isRight, "deploy failed") Utxo(tx.utxos.find(_._2.address == txCreator.scriptAddress).get) } private def registerStake( provider: Emulator, stakeAddress: StakeAddress, witness: ScriptWitness ): Unit = { val utxos = provider.findUtxos(Alice.address).await().toOption.get val tx = TxBuilder(env, evaluator) .registerStake(stakeAddress, witness) .complete(availableUtxos = utxos, sponsor = Alice.address) .sign(Alice.signer) .transaction assert(provider.submit(tx).await().isRight, s"registerStake failed") } // mustMintLogic requires a mint in every tx it runs in, including registration. private def registerMustMintStake(provider: Emulator): Unit = { val utxos = provider.findUtxos(Alice.address).await().toOption.get val tx = TxBuilder(env, evaluator) .registerStake(mustMintStakeAddress, mustMintWitness) .mint(alwaysSucceeds, mintAssets, Data.unit) .complete(availableUtxos = utxos, sponsor = Alice.address) .sign(Alice.signer) .transaction assert(provider.submit(tx).await().isRight, s"registerMustMintStake failed") } test("success: Call with mustNotMint logic validator") { val provider = createProvider() val proxyUtxo = deployProxy(provider, mustNotMintHash) registerStake(provider, mustNotMintStakeAddress, mustNotMintWitness) val utxos = provider.findUtxos(Alice.address).await().toOption.get // no minting -- should pass val tx = txCreator.call( utxos = utxos, proxyUtxo = proxyUtxo, logicStakeAddress = mustNotMintStakeAddress, logicWitness = mustNotMintWitness, sponsor = Alice.address, signer = Alice.signer ) assert(provider.submit(tx).await().isRight, "call with mustNotMint logic failed") val continuationUtxo = tx.utxos.find(_._2.address == txCreator.scriptAddress) assert(continuationUtxo.isDefined, "Continuation UTxO not found") val datum = continuationUtxo.get._2.requireInlineDatum.to[ProxyDatum] assert(datum.logicHash == mustNotMintHash, "Logic hash changed unexpectedly") } test("failure: Call without the logic validator withdrawal") { val provider = createProvider() val proxyUtxo = deployProxy(provider, mustNotMintHash) val utxos = provider.findUtxos(Alice.address).await().toOption.get val proxyDatum = proxyUtxo.output.requireInlineDatum // no withdrawal -- the call should fail val tx = TxBuilder(env, evaluator) .spend(proxyUtxo, ProxyRedeemer.Call, txCreator.script) .payTo(txCreator.scriptAddress, proxyUtxo.output.value, proxyDatum) .complete(availableUtxos = utxos, sponsor = Alice.address) .sign(Alice.signer) .transaction assertSubmitScriptFail(provider, ProxyValidator.LogicNotInvoked)(tx) } test("failure: Call with wrong logic validator withdrawn") { val provider = createProvider() val proxyUtxo = deployProxy(provider, mustNotMintHash) // register the malicious logic registerStake(provider, alwaysSucceedsStakeAddress, alwaysSucceedsWitness) // Withdraw alwaysSucceeds instead of mustNotMint -- should be rejected, as we've swapped out the logic maliciously. val utxos = provider.findUtxos(Alice.address).await().toOption.get val proxyDatum = proxyUtxo.output.requireInlineDatum val tx = TxBuilder(env, evaluator) .spend(proxyUtxo, ProxyRedeemer.Call, txCreator.script) .payTo(txCreator.scriptAddress, proxyUtxo.output.value, proxyDatum) .withdrawRewards(alwaysSucceedsStakeAddress, Coin.zero, alwaysSucceedsWitness) .complete(availableUtxos = utxos, sponsor = Alice.address) .sign(Alice.signer) .transaction assertSubmitScriptFail(provider, ProxyValidator.LogicNotInvoked)(tx) } test("failure: Call when logic validator itself fails") { val provider = createProvider() val proxyUtxo = deployProxy(provider, mustNotMintHash) registerStake(provider, mustNotMintStakeAddress, mustNotMintWitness) val utxos = provider.findUtxos(Alice.address).await().toOption.get val proxyDatum = proxyUtxo.output.requireInlineDatum // The logic forbids minting, but we're trying to mint anyway. val tx = TxBuilder(env, evaluator) .spend(proxyUtxo, ProxyRedeemer.Call, txCreator.script) .payTo(txCreator.scriptAddress, proxyUtxo.output.value, proxyDatum) .withdrawRewards(mustNotMintStakeAddress, Coin.zero, mustNotMintWitness) .mint(alwaysSucceeds, mintAssets, Data.unit) .complete(availableUtxos = utxos, sponsor = Alice.address) .sign(Alice.signer) .transaction assertSubmitScriptFail(provider, "Transaction must not mint")(tx) } test("failure: Call with logic hash changed in continuation datum") { val provider = createProvider() val proxyUtxo = deployProxy(provider, mustNotMintHash) registerStake(provider, mustNotMintStakeAddress, mustNotMintWitness) val utxos = provider.findUtxos(Alice.address).await().toOption.get val oldDatum = proxyUtxo.output.requireInlineDatum.to[ProxyDatum] val tamperedDatum = oldDatum.copy(logicHash = mustMintHash) val tx = TxBuilder(env, evaluator) .spend(proxyUtxo, ProxyRedeemer.Call, txCreator.script) .payTo(txCreator.scriptAddress, proxyUtxo.output.value, tamperedDatum) .withdrawRewards(mustNotMintStakeAddress, Coin.zero, mustNotMintWitness) .complete(availableUtxos = utxos, sponsor = Alice.address) .sign(Alice.signer) .transaction assertSubmitScriptFail(provider, ProxyValidator.LogicHashChanged)(tx) } test("failure: double satisfaction across two proxy inputs sharing one continuation") { val provider = createProvider() val proxyUtxo1 = deployProxy(provider, mustNotMintHash) val proxyUtxo2 = deployProxy(provider, mustNotMintHash) registerStake(provider, mustNotMintStakeAddress, mustNotMintWitness) val utxos = provider.findUtxos(Alice.address).await().toOption.get val proxyDatum = proxyUtxo1.output.requireInlineDatum // Spend BOTH proxy UTxOs but provide only ONE continuation output worth a single proxy's // value. Without a single-input guard both spend validators are satisfied by the same // continuation (each sees it via headOption) and the second proxy's value is swept to the // attacker as change. val tx = TxBuilder(env, evaluator) .spend(proxyUtxo1, ProxyRedeemer.Call, txCreator.script) .spend(proxyUtxo2, ProxyRedeemer.Call, txCreator.script) .payTo(txCreator.scriptAddress, proxyValue, proxyDatum) .withdrawRewards(mustNotMintStakeAddress, Coin.zero, mustNotMintWitness) .complete(availableUtxos = utxos, sponsor = Alice.address) .sign(Alice.signer) .transaction assertSubmitScriptFail(provider, ProxyValidator.MultipleProxyInputs)(tx) } test("success: Upgrade from mustNotMint to mustMint logic with owner signature") { val provider = createProvider() val proxyUtxo = deployProxy(provider, mustNotMintHash) val utxos = provider.findUtxos(Alice.address).await().toOption.get val tx = txCreator.upgrade( utxos = utxos, proxyUtxo = proxyUtxo, newLogicHash = mustMintHash, ownerPkh = Alice.addrKeyHash, sponsor = Alice.address, signer = Alice.signer ) assert(provider.submit(tx).await().isRight, "upgrade failed") val newUtxo = tx.utxos.find(_._2.address == txCreator.scriptAddress) assert(newUtxo.isDefined, "Continuation UTxO not found after upgrade") val newDatum = newUtxo.get._2.requireInlineDatum.to[ProxyDatum] assert(newDatum.logicHash == mustMintHash, "Logic hash not updated to mustMint") } test("success: Call rejects old logic after upgrade") { val provider = createProvider() val proxyUtxo = deployProxy(provider, mustNotMintHash) registerStake(provider, mustNotMintStakeAddress, mustNotMintWitness) val upgradeUtxos = provider.findUtxos(Alice.address).await().toOption.get val upgradeTx = txCreator.upgrade( utxos = upgradeUtxos, proxyUtxo = proxyUtxo, newLogicHash = mustMintHash, ownerPkh = Alice.addrKeyHash, sponsor = Alice.address, signer = Alice.signer ) assert(provider.submit(upgradeTx).await().isRight) val upgradedProxyUtxo = Utxo(upgradeTx.utxos.find(_._2.address == txCreator.scriptAddress).get) val callUtxos = provider.findUtxos(Alice.address).await().toOption.get val tx = TxBuilder(env, evaluator) .spend(upgradedProxyUtxo, ProxyRedeemer.Call, txCreator.script) .payTo( txCreator.scriptAddress, upgradedProxyUtxo.output.value, upgradedProxyUtxo.output.requireInlineDatum ) .withdrawRewards(mustNotMintStakeAddress, Coin.zero, mustNotMintWitness) // old logic .complete(availableUtxos = callUtxos, sponsor = Alice.address) .sign(Alice.signer) .transaction assertSubmitScriptFail(provider, ProxyValidator.LogicNotInvoked)(tx) } test("failure: Upgrade without owner signature") { val provider = createProvider() val proxyUtxo = deployProxy(provider, mustNotMintHash) val utxos = provider.findUtxos(Bob.address).await().toOption.get val oldDatum = proxyUtxo.output.requireInlineDatum.to[ProxyDatum] val newDatum = oldDatum.copy(logicHash = mustMintHash) val tx = TxBuilder(env, evaluator) .spend( proxyUtxo, ProxyRedeemer.Upgrade(mustMintHash), txCreator.script ) .requireSignature(Bob.addrKeyHash) .payTo(txCreator.scriptAddress, proxyUtxo.output.value, newDatum) .complete(availableUtxos = utxos, sponsor = Bob.address) .sign(Bob.signer) .transaction assertSubmitScriptFail(provider, ProxyValidator.NotSignedByOwner)(tx) } test("failure: Upgrade with wrong logic hash in continuation datum") { val provider = createProvider() val proxyUtxo = deployProxy(provider, mustNotMintHash) val utxos = provider.findUtxos(Alice.address).await().toOption.get val oldDatum = proxyUtxo.output.requireInlineDatum val tx = TxBuilder(env, evaluator) .spend( proxyUtxo, ProxyRedeemer.Upgrade(mustMintHash), txCreator.script ) .requireSignature(Alice.addrKeyHash) .payTo(txCreator.scriptAddress, proxyUtxo.output.value, oldDatum) .complete(availableUtxos = utxos, sponsor = Alice.address) .sign(Alice.signer) .transaction assertSubmitScriptFail(provider, ProxyValidator.LogicHashMismatch)(tx) } test("failure: Upgrade with value drained from proxy") { val provider = createProvider() val proxyUtxo = deployProxy(provider, mustNotMintHash) val utxos = provider.findUtxos(Alice.address).await().toOption.get val oldDatum = proxyUtxo.output.requireInlineDatum.to[ProxyDatum] val newDatum = oldDatum.copy(logicHash = mustMintHash) val tx = TxBuilder(env, evaluator) .spend( proxyUtxo, ProxyRedeemer.Upgrade(mustMintHash), txCreator.script ) .requireSignature(Alice.addrKeyHash) .payTo(txCreator.scriptAddress, Value.ada(5), newDatum) .complete(availableUtxos = utxos, sponsor = Alice.address) .sign(Alice.signer) .transaction assertSubmitScriptFail(provider, ProxyValidator.ValueMustBePreserved)(tx) } test("success then failure: mustMint logic works, then fails after upgrade to mustNotMint") { val provider = createProvider() // Deploy with mustMint logic val proxyUtxo = deployProxy(provider, mustMintHash) registerMustMintStake(provider) registerStake(provider, alwaysSucceedsStakeAddress, alwaysSucceedsWitness) // Step 1: Call with mint -- mustMintLogic passes val callUtxos1 = provider.findUtxos(Alice.address).await().toOption.get val proxyDatum = proxyUtxo.output.requireInlineDatum val callTx1 = TxBuilder(env, evaluator) .spend(proxyUtxo, ProxyRedeemer.Call, txCreator.script) .payTo(txCreator.scriptAddress, proxyValue, proxyDatum) .withdrawRewards(mustMintStakeAddress, Coin.zero, mustMintWitness) .mint(alwaysSucceeds, mintAssets, Data.unit) .complete(availableUtxos = callUtxos1, sponsor = Alice.address) .sign(Alice.signer) .transaction assert(provider.submit(callTx1).await().isRight, "first call with mint should succeed") val proxyUtxo2 = Utxo(callTx1.utxos.find(_._2.address == txCreator.scriptAddress).get) // Step 2: Upgrade to mustNotMint logic val upgradeUtxos = provider.findUtxos(Alice.address).await().toOption.get val upgradeTx = txCreator.upgrade( utxos = upgradeUtxos, proxyUtxo = proxyUtxo2, newLogicHash = mustNotMintHash, ownerPkh = Alice.addrKeyHash, sponsor = Alice.address, signer = Alice.signer ) assert(provider.submit(upgradeTx).await().isRight, "upgrade to mustNotMint should succeed") val proxyUtxo3 = Utxo(upgradeTx.utxos.find(_._2.address == txCreator.scriptAddress).get) registerStake(provider, mustNotMintStakeAddress, mustNotMintWitness) // Step 3: Call with mint again -- mustNotMintLogic now rejects it val callUtxos2 = provider.findUtxos(Alice.address).await().toOption.get val callTx2 = TxBuilder(env, evaluator) .spend(proxyUtxo3, ProxyRedeemer.Call, txCreator.script) .payTo(txCreator.scriptAddress, proxyValue, proxyUtxo3.output.requireInlineDatum) .withdrawRewards(mustNotMintStakeAddress, Coin.zero, mustNotMintWitness) .mint(alwaysSucceeds, mintAssets, Data.unit) .complete(availableUtxos = callUtxos2, sponsor = Alice.address) .sign(Alice.signer) .transaction assertSubmitScriptFail(provider, "Transaction must not mint")(callTx2) } } ``` # Example: vault ## scalus-examples/jvm/src/main/scala/scalus/examples/vault/VaultContract.scala ```scala package scalus.examples.vault import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 object VaultContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(VaultValidator.validate) lazy val blueprint = Blueprint.plutusV3[State, Action]( title = "Vault", description = "Keeps the funds safe by requiring a 2-stage withdrawal with a mandatory confirmation period.", version = "1.0.0", license = None, compiled = compiled ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/vault/VaultTransactions.scala ```scala package scalus.examples.vault import scalus.uplc.builtin.{ByteString, Data} import scalus.cardano.address.{Address, ShelleyAddress} import scalus.cardano.ledger.* import scalus.cardano.txbuilder.{TransactionSigner, TxBuilder} import scalus.uplc.PlutusV3 case class VaultTransactions( env: CardanoInfo, evaluator: PlutusScriptEvaluator, signer: TransactionSigner, contract: PlutusV3[Data => Unit] ) { val scriptAddress: Address = contract.address(env.network) private def credentialHash(address: Address): ByteString = address match { case addr: ShelleyAddress => ByteString.fromArray(addr.payment.asHash.bytes) case _ => throw new IllegalArgumentException("Shelley addresses only.") } def lock( utxos: Utxos, ada: Coin, waitTime: Long, owner: Address, recovery: Address, changeAddress: Address ): Transaction = { val datum = State( credentialHash(owner), credentialHash(recovery), Status.Idle, BigInt(ada.value), waitTime, BigInt(0) ) TxBuilder(env, evaluator) .spend(utxos) .payTo(scriptAddress, Value(ada), datum) .build(changeTo = changeAddress) .sign(signer) .transaction } def withdraw( utxos: Utxos, collateralUtxos: Utxos, vaultUtxo: Utxo, changeAddress: Address, validityEndTime: Long ): Transaction = { val currentDatum = vaultUtxo.output match { case TransactionOutput.Babbage(_, _, Some(DatumOption.Inline(d)), _) => d.to[State] case _ => throw new IllegalArgumentException("Vault UTxO must have an inline datum") } // The deadline is anchored to the validity interval's *upper* bound so it cannot be // backdated: the ledger guarantees validTo >= now, hence deadline >= now + waitTime. val requestTime = BigInt(validityEndTime) val finalizationDeadline = requestTime + currentDatum.waitTime val newDatum = currentDatum.copy( status = Status.Pending, finalizationDeadline = finalizationDeadline ) val vaultValue = vaultUtxo.output.value val ownerAddrKeyHash = AddrKeyHash.fromByteString(currentDatum.owner) TxBuilder(env, evaluator) .spend(utxos) .collaterals(collateralUtxos) .spend(vaultUtxo, Action.InitiateWithdrawal, contract) .requireSignature(ownerAddrKeyHash) .validTo(java.time.Instant.ofEpochMilli(validityEndTime)) .payTo(scriptAddress, vaultValue, newDatum) .build(changeTo = changeAddress) .sign(signer) .transaction } def cancel( utxos: Utxos, collateralUtxos: Utxos, vaultUtxo: Utxo, recoveryPkh: AddrKeyHash, changeAddress: Address ): Transaction = { val currentDatum = vaultUtxo.output match { case TransactionOutput.Babbage(_, _, Some(DatumOption.Inline(d)), _) => d.to[State] case _ => throw new IllegalArgumentException("Vault UTxO must have an inline datum") } val newDatum = currentDatum.copy(status = Status.Idle, finalizationDeadline = BigInt(0)) TxBuilder(env, evaluator) .spend(utxos) .collaterals(collateralUtxos) .spend(vaultUtxo, Action.Cancel, contract) .requireSignature(recoveryPkh) .payTo(scriptAddress, vaultUtxo.output.value, newDatum) .build(changeTo = changeAddress) .sign(signer) .transaction } def deposit( utxos: Utxos, collateralUtxos: Utxos, vaultUtxo: Utxo, additionalValue: Value, changeAddress: Address ): Transaction = { val currentDatum = vaultUtxo.output match { case TransactionOutput.Babbage(_, _, Some(DatumOption.Inline(d)), _) => d.to[State] case _ => throw new IllegalArgumentException("Vault UTxO must have an inline datum") } val currentValue = vaultUtxo.output.value val newValue = currentValue + additionalValue val newAmount = BigInt(newValue.coin.value) val newDatum = currentDatum.copy( amount = newAmount, waitTime = currentDatum.waitTime, finalizationDeadline = currentDatum.finalizationDeadline ) TxBuilder(env, evaluator) .spend(utxos) .collaterals(collateralUtxos) .spend(vaultUtxo, Action.Deposit, contract) .payTo(scriptAddress, newValue, newDatum) .build(changeTo = changeAddress) .sign(signer) .transaction } def finalize( utxos: Utxos, collateralUtxos: Utxos, vaultUtxo: Utxo, ownerAddress: Address, changeAddress: Address, validityStartTime: Long ): Transaction = { val currentDatum = vaultUtxo.output match { case TransactionOutput.Babbage(_, _, Some(DatumOption.Inline(d)), _) => d.to[State] case _ => throw new IllegalArgumentException("Vault UTxO must have an inline datum") } val vaultValue = vaultUtxo.output.value TxBuilder(env, evaluator) .spend(utxos) .collaterals(collateralUtxos) .spend(vaultUtxo, Action.FinalizeWithdrawal, contract) .validFrom(java.time.Instant.ofEpochMilli(validityStartTime)) .payTo(ownerAddress, vaultValue) .build(changeTo = changeAddress) .sign(signer) .transaction } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/vault/VaultValidator.scala ```scala package scalus.examples.vault import scalus.compiler.Compile import scalus.uplc.builtin.Data.{toData, FromData, ToData} import scalus.uplc.builtin.{ByteString, Data} import scalus.examples.vault.Action.{Cancel, Deposit, FinalizeWithdrawal, InitiateWithdrawal} import scalus.cardano.onchain.plutus.v1 import scalus.cardano.onchain.plutus.v1.{Credential, PosixTime} import scalus.cardano.onchain.plutus.v2.{OutputDatum, TxOut} import scalus.cardano.onchain.plutus.v3.{TxInInfo, TxInfo, TxOutRef, Validator} import scalus.cardano.onchain.plutus.prelude.{===, fail, require} // Datum case class State( owner: ByteString, recoveryKey: ByteString, status: Status, amount: BigInt, waitTime: PosixTime, finalizationDeadline: PosixTime ) derives FromData, ToData // Redeemer enum Action derives FromData, ToData: case Deposit case InitiateWithdrawal case FinalizeWithdrawal case Cancel enum Status derives FromData, ToData: case Idle case Pending @Compile object Status { extension (s: Status) { def isPending: Boolean = s match { case Status.Idle => false case Status.Pending => true } def isIdle: Boolean = s match { case Status.Idle => true case Status.Pending => false } } } /** A contract for keeping funds. * * Allows withdrawal when 2 conditions are met: a withdrawal request had been issued, and the * specified amount of time has elapsed since the request. * * The withdrawals are allowed only to the address specified in the Datum. * * Additionally, allows to cancel a withdrawal, and add funds to the vault. * * Withdrawal requires 2 actions: 1) Send a `Withdraw` request that contains the Datum-matching * verification key hash. 2) Send a `Finalize` request after a waiting period to confirm the * spending of funds. */ @Compile object VaultValidator extends Validator { inline override def spend( d: scalus.cardano.onchain.plutus.prelude.Option[Data], redeemer: Data, tx: TxInfo, ownRef: TxOutRef ): Unit = { val datum = d.getOrFail(NoDatumExists).to[State] redeemer.to[Action] match { case Deposit => deposit(tx, ownRef, datum) case InitiateWithdrawal => initiateWithdrawal(tx, ownRef, datum) case FinalizeWithdrawal => finalize(tx, ownRef, datum) case Cancel => cancel(tx, ownRef, datum) } } def deposit(tx: TxInfo, ownRef: TxOutRef, datum: State): Unit = { val ownInput = tx.findOwnInputOrFail(ownRef, OwnInputNotFound) val out = getVaultOutput(tx, ownRef) requireSameOwner(out, datum) requireOutputToOwnAddress(ownInput, out, WrongDepositDestination) val value = out.value require(value.withoutLovelace.isZero, CannotAddTokens) require(value.getLovelace > ownInput.resolved.value.getLovelace, AdaNotConserved) requireEntireVaultIsSpent(datum, ownInput.resolved) val newDatum = getVaultDatum(out) require(newDatum.amount == value.getLovelace, VaultAmountChanged) require(newDatum.waitTime == datum.waitTime, WaitTimeChanged) require( newDatum.finalizationDeadline == datum.finalizationDeadline, FinalizationDeadlineChanged ) // A deposit must not change the withdrawal state machine — otherwise anyone could flip a // Pending withdrawal back to Idle (or vice versa) just by adding funds. require(newDatum.status.toData == datum.status.toData, DepositMustNotChangeStatus) } def initiateWithdrawal(tx: TxInfo, ownRef: TxOutRef, datum: State): Unit = { require( datum.status.isIdle, WithdrawalAlreadyPending ) // Owner must sign to initiate withdrawal require( tx.isSignedBy(v1.PubKeyHash(datum.owner)), OwnerMustSign ) val ownInput = tx.findOwnInputOrFail(ownRef, OwnInputNotFound) val out = getVaultOutput(tx, ownRef) requireSameOwner(out, datum) requireOutputToOwnAddress( ownInput, out, NotExactlyOneVaultOutput ) // Verify value is conserved during initiation require( out.value.getLovelace >= ownInput.resolved.value.getLovelace, ValueNotConserved ) // Derive the request time from the validity interval's *upper* bound, not the lower bound. // The lower bound (getValidityStartTime) can be backdated arbitrarily, which would let an // attacker set finalizationDeadline in the past and finalize immediately, defeating the // wait. The ledger guarantees the upper bound is >= now, so deadline >= now + waitTime. val requestTime = tx.validRange.to.finiteOrFail(NoFinalizationUpperBound) val finalizationDeadline = requestTime + datum.waitTime val newDatum = getVaultDatum(out) require(newDatum.status.isPending, MustBePending) require( newDatum.finalizationDeadline == finalizationDeadline, IncorrectDatumFinalization ) } def finalize(tx: TxInfo, ownRef: TxOutRef, datum: State): Unit = { require(datum.status.isPending, ContractMustBePending) require(tx.validRange.isEntirelyAfter(datum.finalizationDeadline), DeadlineNotPassed) val ownInput = tx.findOwnInputOrFail(ownRef, OwnInputNotFound) requireEntireVaultIsSpent(datum, ownInput.resolved) val scriptOutputs = tx.findOwnOutputsByCredential(ownInput.resolved.address.credential) require(scriptOutputs.size == BigInt(0), WithdrawalsMustNotSendBackToVault) val ownerCredential = Credential.PubKeyCredential(v1.PubKeyHash(datum.owner)) val ownerOutputs = tx.findOwnOutputs(out => out.address.credential === ownerCredential) require(ownerOutputs.size > BigInt(0), WrongAddressWithdrawal) val totalToOwner = ownerOutputs.foldLeft(BigInt(0))((acc, out) => acc + out.value.getLovelace) require(totalToOwner >= datum.amount, VaultAmountChanged) } def cancel(tx: TxInfo, ownRef: TxOutRef, datum: State): Unit = { // The recovery key — not the owner — cancels a pending withdrawal. The vault exists to // survive a stolen owner key, so cancellation must use a separate credential the attacker // does not hold. require( tx.isSignedBy(v1.PubKeyHash(datum.recoveryKey)), RecoveryKeyMustSign ) // There must be a pending request to cancel. require(datum.status.isPending, NothingToCancel) val out = getVaultOutput(tx, ownRef) requireSameOwner(out, datum) val vaultDatum = getVaultDatum(out) require(vaultDatum.amount == datum.amount, VaultAmountChanged) require( out.value.getLovelace == datum.amount, WrongOutputAmount ) require(vaultDatum.status.isIdle, StateNotIdle) require(vaultDatum.waitTime == datum.waitTime, WaitTimeChanged) } // Helper functions private def requireEntireVaultIsSpent(datum: State, output: TxOut): Unit = { val amountToSpend = datum.amount val adaSpent = output.value.getLovelace require(amountToSpend == adaSpent, AdaLeftover) } private def requireOutputToOwnAddress(ownInput: TxInInfo, out: TxOut, message: String): Unit = require(out.address.credential === ownInput.resolved.address.credential, message) private def getVaultOutput(tx: TxInfo, ownRef: TxOutRef): TxOut = { val ownInput = tx.findOwnInputOrFail(ownRef, OwnInputNotFound) val scriptOutputs = tx.findOwnOutputsByCredential(ownInput.resolved.address.credential) require(scriptOutputs.size == BigInt(1), NotExactlyOneVaultOutput) scriptOutputs.head } private def getVaultDatum(vaultOutput: TxOut) = vaultOutput.datum match { case OutputDatum.OutputDatum(d) => d.to[State] case _ => fail(NoDatumProvided) } private def requireSameOwner(out: TxOut, datum: State): Unit = out.datum match { case OutputDatum.OutputDatum(newDatum) => val s = newDatum.to[State] require(s.owner == datum.owner, VaultOwnerChanged) require(s.recoveryKey == datum.recoveryKey, RecoveryKeyChanged) case _ => fail(NoInlineDatum) } // Errors inline val NoDatumExists = "Contract has no datum" inline val NoDatumProvided = "Vault transactions must have an inline datum" inline val FinalizationDeadlineChanged = "Deposit transactions must not change the finalization deadline" inline val VaultAmountChanged = "Datum amount must match output lovelace amount" inline val CannotAddTokens = "Deposits must only contain ADA" inline val AdaNotConserved = "Deposits must add ADA to the vault" inline val WrongDepositDestination = "Deposit transactions can only be made to the vault" inline val NotExactlyOneVaultOutput = "Vault transaction must have exactly 1 output to the vault script" inline val OwnInputNotFound = "Own input not found" inline val IncorrectDatumFinalization = "Finalization deadline must be request time plus wait time" inline val MustBePending = "Output must have datum with State = Pending" inline val WithdrawalAlreadyPending = "Cannot withdraw, another withdrawal request is pending" inline val WrongAddressWithdrawal = "Withdrawal finalization must send funds to the vault owner" inline val WithdrawalsMustNotSendBackToVault = "Withdrawal finalization must not send funds back to the vault" inline val DeadlineNotPassed = "Finalization can only happen after the finalization deadline" inline val ContractMustBePending = "Contract must be Pending" inline val WrongOutputAmount = "Cancel transactions must not change the vault amount" inline val WaitTimeChanged = "Wait time must remain the same" inline val StateNotIdle = "Idle transactions must change the vault state to Idle" inline val NoInlineDatum = "Vault transactions must have an inline datum" inline val VaultOwnerChanged = "Vault transactions cannot change the vault owner" inline val AdaLeftover = "Must spend entire vault" inline val OwnerMustSign = "Owner must sign to initiate a withdrawal" inline val ValueNotConserved = "Value must be conserved during initiation" inline val NoFinalizationUpperBound = "Withdrawal request must set a finite validity upper bound" inline val DepositMustNotChangeStatus = "Deposits must not change the vault status" inline val RecoveryKeyChanged = "Vault transactions cannot change the recovery key" inline val RecoveryKeyMustSign = "Recovery key must sign to cancel a withdrawal" inline val NothingToCancel = "Cannot cancel: no withdrawal is pending" } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/vault/VaultTransactionTest.scala ```scala package scalus.examples.vault import org.scalatest.funsuite.AnyFunSuite import scalus.uplc.builtin.platform import scalus.cardano.ledger.* import scalus.cardano.ledger.rules.* import scalus.cardano.ledger.utils.AllResolvedScripts import scalus.cardano.node.Emulator import scalus.cardano.txbuilder.{RedeemerPurpose, TransactionSigner} import scalus.examples.vault.State import scalus.testing.kit.TestUtil.{genesisHash, getScriptContextV3} import scalus.testing.kit.{ScalusTest, TestUtil} import scalus.uplc.eval.Result import scalus.utils.await class VaultTransactionTest extends AnyFunSuite, ScalusTest { private given env: CardanoInfo = TestUtil.testEnvironment private val contract = VaultContract.compiled.withErrorTraces private val scriptAddress = contract.address(env.network) // Generate real key pairs private val ownerKeyPair @ (ownerPrivateKey, ownerPublicKey) = generateKeyPair() private val ownerSigner = TransactionSigner(Set(ownerKeyPair)) private val ownerPkh = AddrKeyHash(platform.blake2b_224(ownerPublicKey)) private val ownerAddress = TestUtil.createTestAddress(ownerPkh) // Separate recovery key — the credential allowed to cancel a pending withdrawal. private val recoveryKeyPair @ (_, recoveryPublicKey) = generateKeyPair() private val recoverySigner = TransactionSigner(Set(recoveryKeyPair)) private val recoveryPkh = AddrKeyHash(platform.blake2b_224(recoveryPublicKey)) private val recoveryAddress = TestUtil.createTestAddress(recoveryPkh) private val defaultInitialAmount: Coin = Coin.ada(10) private val defaultWaitTime: Long = 10_000L private val commissionAmount = Coin(2_000_000L) // Transaction creator factories private def transactionCreatorFor(signer: TransactionSigner) = VaultTransactions( env = env, evaluator = PlutusScriptEvaluator(env, EvaluatorMode.EvaluateAndComputeCost), signer = signer, contract = contract ) private def transactionCreatorWithConstEvaluatorFor(signer: TransactionSigner) = VaultTransactions( env = env, evaluator = PlutusScriptEvaluator.constMaxBudget(env), signer = signer, contract = contract ) // Provider factory private def createProvider(): Emulator = { Emulator( initialUtxos = Map( Input(genesisHash, 0) -> Output(address = ownerAddress, value = Value.ada(100)), // Fund the recovery key holder so they can pay fees for a cancel without the owner key. Input(genesisHash, 1) -> Output(address = recoveryAddress, value = Value.ada(100)) ), initialContext = Context.testMainnet(), mutators = Set(PlutusScriptsTransactionMutator) ) } private def runValidator( provider: Emulator, tx: Transaction, scriptInput: TransactionInput ): Result = { val utxos = { val body = tx.body.value val allInputs = (body.inputs.toSet.view ++ body.collateralInputs.toSet.view ++ body.referenceInputs.toSet.view).toSet provider.findUtxos(allInputs).await().toOption.get } val scriptContext = tx.getScriptContextV3(utxos, RedeemerPurpose.ForSpend(scriptInput)) val allResolvedPlutusScriptsMap = AllResolvedScripts.allResolvedPlutusScriptsMap(tx, utxos).toOption.get val plutusScript = scriptAddress.scriptHashOption.flatMap(allResolvedPlutusScriptsMap.get).get val program = plutusScript.deBruijnedProgram.toProgram program.runWithDebug(scriptContext) } test("vault withdrawal request") { val provider = createProvider() val lockTx = { val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(defaultInitialAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(ownerSigner) .lock( utxos, defaultInitialAmount, defaultWaitTime, ownerAddress, recoveryAddress, ownerAddress ) } assert(provider.submit(lockTx).await().isRight) val vaultUtxo = Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == lockTx.id && u.output.value.coin >= defaultInitialAmount } .execute() .await() .toOption .get .head ) assert(vaultUtxo.output.value.coin == defaultInitialAmount) val currentSlot = 1000L // validity upper bound is one slot ahead so the tx is still valid when submitted at currentSlot val validityEndTime = env.slotConfig.slotToTime(currentSlot + 1) val withdrawTx = { val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(ownerSigner) .withdraw(utxos, utxos, vaultUtxo, ownerAddress, validityEndTime) } val result = runValidator(provider, withdrawTx, vaultUtxo.input) assert(result.isSuccess) assert( result.budget == (ExUnits(memory = 142879, steps = 46_715168)) ) provider.setSlot(currentSlot) assert(provider.submit(withdrawTx).await().isRight) val newVaultUtxo = Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == withdrawTx.id && u.output.value.coin >= defaultInitialAmount } .execute() .await() .toOption .get .head ) newVaultUtxo.output match { case TransactionOutput.Babbage(_, _, Some(DatumOption.Inline(d)), _) => val newDatum = d.to[State] assert( newDatum.status == Status.Pending, s"Vault state should be Pending, got ${newDatum.status}" ) assert( newDatum.amount == defaultInitialAmount.value, "Vault amount should remain unchanged" ) assert( newDatum.finalizationDeadline > 0, "Finalization deadline should be set" ) case _ => fail("Vault output should have inline datum") } } test("vault deposit adds funds") { val provider = createProvider() val lockTx = { val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(defaultInitialAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(ownerSigner) .lock( utxos, defaultInitialAmount, defaultWaitTime, ownerAddress, recoveryAddress, ownerAddress ) } assert(provider.submit(lockTx).await().isRight) val vaultUtxo = Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == lockTx.id && u.output.value.coin >= defaultInitialAmount } .execute() .await() .toOption .get .head ) val depositAmount = Value.lovelace(5_000_000L) val depositTx = { val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(depositAmount.coin + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(ownerSigner) .deposit(utxos, utxos, vaultUtxo, depositAmount, ownerAddress) } val result = runValidator(provider, depositTx, vaultUtxo.input) assert(result.isSuccess, s"Deposit should succeed: $result") assert( result.budget == (ExUnits(memory = 161442, steps = 50_487004)) ) assert(provider.submit(depositTx).await().isRight) val newVaultUtxo = Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == depositTx.id && u.output.value.coin >= defaultInitialAmount + depositAmount.coin } .execute() .await() .toOption .get .head ) newVaultUtxo.output match { case TransactionOutput.Babbage(_, value, Some(DatumOption.Inline(d)), _) => val newDatum = d.to[State] assert( newDatum.status == Status.Idle, s"Vault state should remain Idle, got ${newDatum.status}" ) assert( newDatum.amount == BigInt((defaultInitialAmount + depositAmount.coin).value), s"Vault amount should be ${(defaultInitialAmount + depositAmount.coin).value}, got ${newDatum.amount}" ) assert( value.coin.value == (defaultInitialAmount + depositAmount.coin).value, s"Vault value should match datum amount" ) case _ => fail("Vault output should have inline datum") } } test("vault finalization fails when vault is in Idle state") { val provider = createProvider() val lockTx = { val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(defaultInitialAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(ownerSigner) .lock( utxos, defaultInitialAmount, defaultWaitTime, ownerAddress, recoveryAddress, ownerAddress ) } assert(provider.submit(lockTx).await().isRight) val vaultUtxo = Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == lockTx.id && u.output.value.coin >= defaultInitialAmount } .execute() .await() .toOption .get .head ) val currentTime = env.slotConfig.slotToTime(1000L) val finalizeTx = { val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(commissionAmount) .execute() .await() .toOption .get transactionCreatorWithConstEvaluatorFor(ownerSigner) .finalize(utxos, utxos, vaultUtxo, ownerAddress, ownerAddress, currentTime) } val result = runValidator(provider, finalizeTx, vaultUtxo.input) assert(result.isFailure, "Finalize on Idle vault should fail") assert(result.logs.last.contains(VaultValidator.ContractMustBePending)) } test("vault finalization succeeds after withdrawal request") { val provider = createProvider() val lockTx = { val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(defaultInitialAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(ownerSigner) .lock( utxos, defaultInitialAmount, defaultWaitTime, ownerAddress, recoveryAddress, ownerAddress ) } assert(provider.submit(lockTx).await().isRight) val vaultUtxo = Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == lockTx.id && u.output.value.coin >= defaultInitialAmount } .execute() .await() .toOption .get .head ) val withdrawSlot = 1000L val withdrawTime = env.slotConfig.slotToTime(withdrawSlot + 1) val withdrawTx = { val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(ownerSigner) .withdraw(utxos, utxos, vaultUtxo, ownerAddress, withdrawTime) } val withdrawResult = runValidator(provider, withdrawTx, vaultUtxo.input) assert(withdrawResult.isSuccess, s"Withdraw should succeed: $withdrawResult") assert( withdrawResult.budget == (ExUnits(memory = 142879, steps = 46_715168)) ) provider.setSlot(withdrawSlot) assert(provider.submit(withdrawTx).await().isRight) val pendingVaultUtxo = Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == withdrawTx.id && u.output.value.coin >= defaultInitialAmount } .execute() .await() .toOption .get .head ) // Calculate finalization time (after wait time; deadline is anchored to withdrawSlot + 1) val finalizeSlot = withdrawSlot + 1 + (defaultWaitTime / env.slotConfig.slotLength) + 1 val finalizeTime = env.slotConfig.slotToTime(finalizeSlot) val finalizeTx = { val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(ownerSigner) .finalize( utxos, utxos, pendingVaultUtxo, ownerAddress, ownerAddress, finalizeTime ) } val finalizeResult = runValidator(provider, finalizeTx, pendingVaultUtxo.input) assert(finalizeResult.isSuccess, s"Finalize should succeed: $finalizeResult") assert( finalizeResult.budget == (ExUnits(memory = 160990, steps = 46_856204)) ) provider.setSlot(finalizeSlot) assert(provider.submit(finalizeTx).await().isRight) val scriptOutputs = finalizeTx.body.value.outputs.filter(_.value.address.hasScript) assert(scriptOutputs.isEmpty, "Finalize should close vault (no script outputs)") val ownerOutputs = finalizeTx.body.value.outputs.filter { output => output.value.address == ownerAddress } assert(ownerOutputs.nonEmpty, "Finalize should send funds to owner") val ownerReceivedValue = ownerOutputs.map(_.value.value.coin.value).sum assert( ownerReceivedValue >= defaultInitialAmount.value, s"Owner should receive at least the vault amount, got $ownerReceivedValue" ) } test("vault finalization fails before wait time elapses") { val provider = createProvider() val lockTx = { val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(defaultInitialAmount + commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(ownerSigner) .lock( utxos, defaultInitialAmount, defaultWaitTime, ownerAddress, recoveryAddress, ownerAddress ) } assert(provider.submit(lockTx).await().isRight) val vaultUtxo = Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == lockTx.id && u.output.value.coin >= defaultInitialAmount } .execute() .await() .toOption .get .head ) val withdrawSlot = 1000L val withdrawTime = env.slotConfig.slotToTime(withdrawSlot + 1) val withdrawTx = { val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(commissionAmount) .execute() .await() .toOption .get transactionCreatorFor(ownerSigner) .withdraw(utxos, utxos, vaultUtxo, ownerAddress, withdrawTime) } val withdrawResult = runValidator(provider, withdrawTx, vaultUtxo.input) assert(withdrawResult.isSuccess, s"Withdraw should succeed: $withdrawResult") provider.setSlot(withdrawSlot) assert(provider.submit(withdrawTx).await().isRight) val pendingVaultUtxo = Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == withdrawTx.id && u.output.value.coin >= defaultInitialAmount } .execute() .await() .toOption .get .head ) // Try to finalize just after withdrawal (before wait time) val earlySlot = withdrawSlot + 1 val earlyTime = env.slotConfig.slotToTime(earlySlot) val finalizeTx = { val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(commissionAmount) .execute() .await() .toOption .get transactionCreatorWithConstEvaluatorFor(ownerSigner) .finalize( utxos, utxos, pendingVaultUtxo, ownerAddress, ownerAddress, earlyTime ) } val finalizeResult = runValidator(provider, finalizeTx, pendingVaultUtxo.input) assert(finalizeResult.isFailure, "Finalize before wait time should fail") assert(finalizeResult.logs.last.contains(VaultValidator.DeadlineNotPassed)) } /** Locks a vault and moves it to Pending via a withdrawal request; returns the pending UTxO. */ private def lockAndRequest(provider: Emulator): Utxo = { val lockUtxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(defaultInitialAmount + commissionAmount) .execute() .await() .toOption .get val lockTx = transactionCreatorFor(ownerSigner) .lock( lockUtxos, defaultInitialAmount, defaultWaitTime, ownerAddress, recoveryAddress, ownerAddress ) assert(provider.submit(lockTx).await().isRight) val vaultUtxo = Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == lockTx.id } .execute() .await() .toOption .get .head ) val withdrawSlot = 1000L val withdrawTime = env.slotConfig.slotToTime(withdrawSlot + 1) val withdrawUtxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(commissionAmount) .execute() .await() .toOption .get val withdrawTx = transactionCreatorFor(ownerSigner) .withdraw(withdrawUtxos, withdrawUtxos, vaultUtxo, ownerAddress, withdrawTime) provider.setSlot(withdrawSlot) assert(provider.submit(withdrawTx).await().isRight) Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == withdrawTx.id } .execute() .await() .toOption .get .head ) } test("recovery key cancels a pending withdrawal") { val provider = createProvider() val pendingVaultUtxo = lockAndRequest(provider) val utxos = provider .queryUtxos { u => u.output.address == recoveryAddress } .minTotal(commissionAmount) .execute() .await() .toOption .get val cancelTx = transactionCreatorFor(recoverySigner) .cancel(utxos, utxos, pendingVaultUtxo, recoveryPkh, recoveryAddress) val result = runValidator(provider, cancelTx, pendingVaultUtxo.input) assert(result.isSuccess, s"Recovery-key cancel should succeed: $result") assert(provider.submit(cancelTx).await().isRight) val idleVault = Utxo( provider .queryUtxos { u => u.output.address == scriptAddress && u.input.transactionId == cancelTx.id } .execute() .await() .toOption .get .head ) idleVault.output match { case TransactionOutput.Babbage(_, _, Some(DatumOption.Inline(d)), _) => assert(d.to[State].status == Status.Idle, "Cancel should return the vault to Idle") case _ => fail("Vault output should have inline datum") } } test("owner cannot cancel a withdrawal — only the recovery key can") { val provider = createProvider() val pendingVaultUtxo = lockAndRequest(provider) val utxos = provider .queryUtxos { u => u.output.address == ownerAddress } .minTotal(commissionAmount) .execute() .await() .toOption .get // The owner signs and points the cancel at their own key — the threat model is a stolen // owner key, so the validator must reject this and require the separate recovery key. val cancelTx = transactionCreatorWithConstEvaluatorFor(ownerSigner) .cancel(utxos, utxos, pendingVaultUtxo, ownerPkh, ownerAddress) val result = runValidator(provider, cancelTx, pendingVaultUtxo.input) assert(result.isFailure, "Owner-signed cancel must fail") assert(result.logs.last.contains(VaultValidator.RecoveryKeyMustSign)) } } ``` # Example: vesting ## scalus-examples/jvm/src/main/scala/scalus/examples/vesting/VestingContract.scala ```scala package scalus.examples.vesting import scalus.cardano.blueprint.{Blueprint, Contract} import scalus.compiler.Options import scalus.uplc.PlutusV3 object VestingContract extends Contract { private given Options = Options.release lazy val compiled = PlutusV3.compile(VestingValidator.validate) lazy val blueprint = Blueprint.plutusV3[Config, Action]( title = "Vesting validator", description = "Time-locked token distribution with linear vesting schedule", version = "1.0.0", license = Some("Apache License Version 2.0"), compiled = compiled ) } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/vesting/VestingTransactions.scala ```scala package scalus.examples.vesting import scalus.uplc.builtin.Data import scalus.cardano.address.Address import scalus.cardano.ledger.* import scalus.cardano.txbuilder.* import scalus.cardano.onchain.plutus.v1.PubKeyHash import scalus.uplc.PlutusV3 import java.time.Instant /** Transaction builder for Vesting contract operations. */ case class VestingTransactions( env: CardanoInfo, contract: PlutusV3[Data => Unit] ) { private val scriptAddress: Address = contract.address(env.network) private val builder = TxBuilder(env) // Separate builder for withdraw: the validator performs strict fee accounting // (adaInOutputs === requestedAmount + adaInInputs - fee) which fails during // TxBuilder's iterative balancing because the script is evaluated before fee/change // values converge. constMaxBudget skips script evaluation during building; // the actual validation happens at submission time (emulator or node). private val withdrawBuilder = TxBuilder.withConstMaxBudgetEvaluator(env) def lock( utxos: Utxos, value: Value, sponsor: Address, beneficiary: AddrKeyHash, startTimestamp: Long, duration: Long, signer: TransactionSigner ): Transaction = { val datum = Config( PubKeyHash(beneficiary), BigInt(startTimestamp), BigInt(duration), BigInt(value.coin.value) ) builder .payTo(scriptAddress, value, datum) .complete(availableUtxos = utxos, sponsor = sponsor) .sign(signer) .transaction } def withdraw( utxos: Utxos, vestingUtxo: Utxo, amount: Long, beneficiaryAddress: Address, beneficiaryPkh: AddrKeyHash, sponsor: Address, validFrom: Instant, signer: TransactionSigner ): Transaction = { require(amount > 0, "Withdrawal amount must be positive") val datum = vestingUtxo.output.requireInlineDatum val redeemer = Action(BigInt(amount)) val contractAmount = vestingUtxo.output.value.coin.value val b = withdrawBuilder .spend(vestingUtxo, redeemer, contract) .requireSignature(beneficiaryPkh) .payTo(beneficiaryAddress, Value.lovelace(amount)) .validFrom(validFrom) val b2 = if amount == contractAmount then b else b.payTo(scriptAddress, Value.lovelace(contractAmount - amount), datum) b2.complete(availableUtxos = utxos, sponsor) .sign(signer) .transaction } } ``` ## scalus-examples/jvm/src/main/scala/scalus/examples/vesting/VestingValidator.scala ```scala package scalus.examples.vesting import scalus.compiler.Compile import scalus.uplc.builtin.Data import scalus.uplc.builtin.Data.{FromData, ToData} import scalus.cardano.onchain.plutus.v1.Value import scalus.cardano.onchain.plutus.v1.Value.* import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.prelude.Option.* // Datum case class Config( beneficiary: PubKeyHash, startTimestamp: PosixTime, duration: PosixTime, initialAmount: Lovelace ) derives FromData, ToData // Redeemer case class Action(amount: Lovelace) derives FromData, ToData /** Locks up funds and allows the beneficiary to withdraw the funds after the lockup period * * When a new employee joins an organization, they typically receive a promise of compensation to * be disbursed after a specified duration of employment. This arrangement often involves the * organization depositing the funds into a vesting contract, with the employee gaining access to * the funds upon the completion of a predetermined lockup period. Through the utilization of * vesting contracts, organizations establish a mechanism to encourage employee retention by * linking financial rewards to tenure. * * @see * [[https://github.com/blockchain-unica/rosetta-smart-contracts/tree/main/contracts/vesting]] * [[https://meshjs.dev/smart-contracts/vesting]] * [[https://github.com/cardano-foundation/cardano-template-and-ecosystem-monitoring/tree/main/vesting]] */ @Compile object VestingValidator extends Validator { inline override def spend( datum: Option[Data], redeemer: Data, txInfo: TxInfo, txOutRef: TxOutRef ): Unit = { val vestingDatum = datum.getOrFail(DatumNotFound) val vestingConfig = vestingDatum.to[Config] val Action(requestedAmount) = redeemer.to[Action] require(requestedAmount > 0, NonPositiveAmount) val ownInput = txInfo.findOwnInputOrFail(txOutRef).resolved val contractAddress = ownInput.address // Reject spending more than one vesting UTxO at once: otherwise a single continuing // output could satisfy several script inputs (double satisfaction) and the remaining // locked funds of the extra inputs would be siphoned off. require( txInfo.findOwnInputsByCredential(contractAddress.credential).length === BigInt(1), MultipleVestingInputs ) val contractAmount = ownInput.value.getLovelace val contractOutputs = txInfo.findOwnOutputsByCredential(contractAddress.credential) val txEarliestTime = txInfo.getValidityStartTime val released = vestingConfig.initialAmount - contractAmount val availableAmount = linearVesting(vestingConfig, txEarliestTime) - released require( txInfo.isSignedBy(vestingConfig.beneficiary), NoBeneficiarySignature ) require( requestedAmount <= availableAmount, AmountExceedsAvailable ) val beneficiaryCred = Credential.PubKeyCredential(vestingConfig.beneficiary) val beneficiaryInputs = txInfo.findOwnInputsByCredential(beneficiaryCred) val beneficiaryOutputs = txInfo.findOwnOutputsByCredential(beneficiaryCred) val adaInInputs = Utils.getAdaFromInputs(beneficiaryInputs) val adaInOutputs = Utils.getAdaFromOutputs(beneficiaryOutputs) val expectedOutput = requestedAmount + adaInInputs - txInfo.fee require( adaInOutputs === expectedOutput, BeneficiaryOutputMismatch ) if requestedAmount === contractAmount then () else require(contractOutputs.length === BigInt(1), NotExactlyOneContractOutput) val contractOutput = contractOutputs.head // Pin the continuing output to the exact own input address: matching the payment // credential alone would let the staking credential (and thus delegation rewards) // be redirected to the attacker. require(contractOutput.address === ownInput.address, ContinuingAddressMismatch) // The continuing output must preserve the entire remaining value — ADA and any // native tokens — minus only the withdrawn lovelace. A lovelace-only check would // let native tokens be stripped out of the locked UTxO. require( contractOutput.value === ownInput.value - Value.lovelace(requestedAmount), ContinuingValueMismatch ) require( contractOutput.datum === OutputDatum.OutputDatum(vestingDatum), InvalidDatum ) } def linearVesting(vestingDatum: Config, timestamp: BigInt): BigInt = { val min = vestingDatum.startTimestamp val max = vestingDatum.startTimestamp + vestingDatum.duration if timestamp < min then 0 else if timestamp >= max then vestingDatum.initialAmount else vestingDatum.initialAmount * (timestamp - vestingDatum.startTimestamp) / vestingDatum.duration } // Error messages inline val DatumNotFound = "Datum not found" inline val NonPositiveAmount = "Withdrawal amount must be greater than 0" inline val MultipleVestingInputs = "Only one vesting input may be spent per transaction" inline val NoBeneficiarySignature = "No signature from beneficiary" inline val AmountExceedsAvailable = "Requested amount exceeds the available vested amount" inline val BeneficiaryOutputMismatch = "Beneficiary output mismatch" inline val NotExactlyOneContractOutput = "Expected exactly one contract output" inline val ContinuingAddressMismatch = "Continuing output must keep the vesting address" inline val ContinuingValueMismatch = "Continuing output must preserve the remaining vested value" inline val InvalidDatum = "VestingDatum mismatch" } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/vesting/VestingTransactionTest.scala ```scala package scalus.examples.vesting import org.scalatest.funsuite.AnyFunSuite import scalus.cardano.ledger.* import scalus.cardano.ledger.utils.AllResolvedScripts import scalus.cardano.node.{Emulator, NodeSubmitError} import scalus.cardano.txbuilder.RedeemerPurpose import scalus.testing.kit.Party.{Alice, Bob, Eve} import scalus.testing.kit.TestUtil.getScriptContextV3 import scalus.testing.kit.{ScalusTest, TestUtil} import scalus.uplc.eval.Result import scalus.utils.await class VestingTransactionTest extends AnyFunSuite, ScalusTest { // Lowering of the prelude's `===` macro-spliced application trips the // not-provably-default Eq heuristic; the warning is a known false positive here. private given scalus.compiler.Options = scalus.compiler.Options.default.copy(noWarn = true) private given env: CardanoInfo = TestUtil.testEnvironment private val contract = VestingContract.compiled.withErrorTraces private val txCreator = VestingTransactions( env = env, contract = contract ) // Time model: vesting starts at slot 10, lasts 100 slots (100 seconds) private val vestingStartSlot: SlotNo = 10 private val vestingDurationSlots: Long = 100 private val startTimestamp: Long = env.slotConfig.slotToTime(vestingStartSlot) private val duration: Long = vestingDurationSlots * env.slotConfig.slotLength private val lockAmount: Long = 20_000_000L // 20 ADA private val scriptAddress = contract.address(env.network) private def createProvider: Emulator = Emulator.withAddresses(Seq(Alice.address, Bob.address, Eve.address)) private def runValidator( provider: Emulator, tx: Transaction, scriptInput: TransactionInput ): Result = { val utxos = { val body = tx.body.value val allInputs = (body.inputs.toSet.view ++ body.collateralInputs.toSet.view ++ body.referenceInputs.toSet.view).toSet provider.findUtxos(allInputs).await().toOption.get } val scriptContext = tx.getScriptContextV3(utxos, RedeemerPurpose.ForSpend(scriptInput)) val allResolvedPlutusScriptsMap = AllResolvedScripts.allResolvedPlutusScriptsMap(tx, utxos).toOption.get val plutusScript = scriptAddress.scriptHashOption.flatMap(allResolvedPlutusScriptsMap.get).get val program = plutusScript.deBruijnedProgram.toProgram program.runWithDebug(scriptContext) } private def lock(provider: Emulator): Utxo = { val utxos = provider.findUtxos(address = Alice.address).await().toOption.get val lockTx = txCreator.lock( utxos = utxos, value = Value.lovelace(lockAmount), sponsor = Alice.address, beneficiary = Bob.addrKeyHash, startTimestamp = startTimestamp, duration = duration, signer = Alice.signer ) assert(provider.submit(lockTx).await().isRight) val lockedUtxo = lockTx.utxos.find { case (_, txOut) => txOut.address == contract.address(env.network) }.get Utxo(lockedUtxo) } /** Asserts that submitting a transaction to the emulator fails with a script error containing * the expected message. * * Since VestingTransactions uses constMaxBudget (script is not evaluated during building), * validation errors are detected at emulator submission time rather than build time. */ private def assertSubmitScriptFail(provider: Emulator, expectedError: String)( tx: Transaction ): Unit = { val result = provider.submit(tx).await() result match case Right(_) => fail(s"Transaction submission should have failed but succeeded") case Left(sf: NodeSubmitError.ScriptFailure) => if !sf.logs.exists(_.contains(expectedError)) then fail( s"Expected error containing '$expectedError' but got logs: ${sf.logs.mkString(", ")}" ) case Left(other) => fail(s"Expected ScriptFailure but got: $other") } test("Lock creates correct UTxO") { val provider = createProvider val lockedUtxo = lock(provider) assert(lockedUtxo.output.value.coin.value == lockAmount) assert(lockedUtxo.output.address == contract.address(env.network)) val datum = lockedUtxo.output.requireInlineDatum.to[Config] assert(datum.beneficiary == scalus.cardano.onchain.plutus.v1.PubKeyHash(Bob.addrKeyHash)) assert(datum.startTimestamp == BigInt(startTimestamp)) assert(datum.duration == BigInt(duration)) assert(datum.initialAmount == BigInt(lockAmount)) } test("Full withdrawal after vesting ends") { val provider = createProvider val lockedUtxo = lock(provider) // Advance past vesting end val afterEndSlot: SlotNo = vestingStartSlot + vestingDurationSlots + 1 provider.setSlot(afterEndSlot) val utxos = provider.findUtxos(Bob.address).await().toOption.get val validFrom = env.slotConfig.slotToInstant(afterEndSlot) val withdrawTx = txCreator.withdraw( utxos = utxos, vestingUtxo = lockedUtxo, amount = lockAmount, beneficiaryAddress = Bob.address, beneficiaryPkh = Bob.addrKeyHash, sponsor = Bob.address, validFrom = validFrom, signer = Bob.signer ) val result = runValidator(provider, withdrawTx, lockedUtxo.input) assert(result.isSuccess, s"Validator failed: $result") assert( result.budget == (ExUnits(memory = 238332, steps = 73_640224)) ) val submitResult = provider.submit(withdrawTx).await() assert(submitResult.isRight, s"Full withdrawal failed: $submitResult") } test("Partial 50% withdrawal at midpoint") { val provider = createProvider val lockedUtxo = lock(provider) // Advance to midpoint val midSlot: SlotNo = vestingStartSlot + vestingDurationSlots / 2 provider.setSlot(midSlot) val utxos = provider.findUtxos(Bob.address).await().toOption.get val validFrom = env.slotConfig.slotToInstant(midSlot) val withdrawAmount = lockAmount / 2 val withdrawTx = txCreator.withdraw( utxos = utxos, vestingUtxo = lockedUtxo, amount = withdrawAmount, beneficiaryAddress = Bob.address, beneficiaryPkh = Bob.addrKeyHash, sponsor = Bob.address, validFrom = validFrom, signer = Bob.signer ) val result = runValidator(provider, withdrawTx, lockedUtxo.input) assert(result.isSuccess, s"Validator failed: $result") assert( result.budget == (ExUnits(memory = 345045, steps = 106_501560)) ) val submitResult = provider.submit(withdrawTx).await() assert(submitResult.isRight, s"Partial withdrawal failed: $submitResult") // Verify continuing output with preserved datum val continuingUtxo = withdrawTx.utxos.find { case (_, txOut) => txOut.address == contract.address(env.network) }.get assert(continuingUtxo._2.value.coin.value == lockAmount - withdrawAmount) val continuingDatum = continuingUtxo._2.requireInlineDatum.to[Config] assert(continuingDatum.initialAmount == BigInt(lockAmount)) } test("Fail: withdraw before vesting starts") { val provider = createProvider val lockedUtxo = lock(provider) // Before vesting starts val beforeStartSlot: SlotNo = vestingStartSlot - 1 provider.setSlot(beforeStartSlot) val utxos = provider.findUtxos(Bob.address).await().toOption.get val validFrom = env.slotConfig.slotToInstant(beforeStartSlot) val tx = txCreator.withdraw( utxos = utxos, vestingUtxo = lockedUtxo, amount = lockAmount, beneficiaryAddress = Bob.address, beneficiaryPkh = Bob.addrKeyHash, sponsor = Bob.address, validFrom = validFrom, signer = Bob.signer ) assertSubmitScriptFail(provider, "Requested amount exceeds the available vested amount")(tx) } test("Fail: withdraw more than available") { val provider = createProvider val lockedUtxo = lock(provider) // At 25% of vesting val quarterSlot: SlotNo = vestingStartSlot + vestingDurationSlots / 4 provider.setSlot(quarterSlot) val utxos = provider.findUtxos(Bob.address).await().toOption.get val validFrom = env.slotConfig.slotToInstant(quarterSlot) // Try to withdraw 50% when only 25% is available val tx = txCreator.withdraw( utxos = utxos, vestingUtxo = lockedUtxo, amount = lockAmount / 2, beneficiaryAddress = Bob.address, beneficiaryPkh = Bob.addrKeyHash, sponsor = Bob.address, validFrom = validFrom, signer = Bob.signer ) assertSubmitScriptFail(provider, "Requested amount exceeds the available vested amount")(tx) } test("Fail: wrong signer") { val provider = createProvider val lockedUtxo = lock(provider) // After vesting ends val afterEndSlot: SlotNo = vestingStartSlot + vestingDurationSlots + 1 provider.setSlot(afterEndSlot) val utxos = provider.findUtxos(Eve.address).await().toOption.get val validFrom = env.slotConfig.slotToInstant(afterEndSlot) val tx = txCreator.withdraw( utxos = utxos, vestingUtxo = lockedUtxo, amount = lockAmount, beneficiaryAddress = Eve.address, beneficiaryPkh = Eve.addrKeyHash, sponsor = Eve.address, validFrom = validFrom, signer = Eve.signer ) assertSubmitScriptFail(provider, "No signature from beneficiary")(tx) } } ``` ## scalus-examples/jvm/src/test/scala/scalus/examples/vesting/VestingValidatorTest.scala ```scala package scalus.examples.vesting import org.scalatest.funsuite.AnyFunSuite import scalus.* import scalus.uplc.builtin.Data import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.ByteString.hex import scalus.compiler.sir.TargetLoweringBackend import scalus.compiler.{compileWithOptions, Options} import scalus.cardano.onchain.plutus.v1.Credential.ScriptCredential import scalus.cardano.onchain.plutus.v1.{Address, Credential, PubKeyHash, StakingCredential} import scalus.cardano.onchain.plutus.v2.OutputDatum import scalus.cardano.onchain.plutus.v3.* import scalus.cardano.onchain.plutus.prelude.* import scalus.cardano.onchain.plutus.prelude.Option.* import scalus.testing.kit.{ScalusTest, TestUtil} import scalus.uplc.eval.* import scala.language.implicitConversions class VestingValidatorTest extends AnyFunSuite, ScalusTest { private val ownerPKH: PubKeyHash = TestUtil.mockPubKeyHash(0) private val beneficiaryPKH: PubKeyHash = TestUtil.mockPubKeyHash(1) private val contractHash: ValidatorHash = TestUtil.mockScriptHash(0) private val defaultStartTime: PosixTime = BigInt(1609459200000L) private val defaultDuration: PosixTime = BigInt(31536000000L) private val defaultInitialAmount: Lovelace = BigInt(20_000_000L) private val defaultFee: Lovelace = BigInt(1_000_000L) given Options = Options( targetLoweringBackend = TargetLoweringBackend.SirToUplcV3Lowering, generateErrorTraces = true, optimizeUplc = false, debug = false ) private inline def compiled(using options: Options) = { compileWithOptions(options, VestingValidator.validate) } case class TestCase( signatories: List[PubKeyHash], interval: Interval, vestingDatum: Config, redeemer: Action, beneficiaryInputAmount: Lovelace = BigInt(0), fee: Lovelace = defaultFee ) def checkTestCase(testCase: TestCase): Result = { val vestingDatum = testCase.vestingDatum val signatories = testCase.signatories val interval = testCase.interval val redeemer = testCase.redeemer val beneficiaryInputAmount = testCase.beneficiaryInputAmount val fee = testCase.fee val inputs = List( makeScriptHashInput( contractHash, vestingDatum.initialAmount ), makePubKeyHashInput( beneficiaryPKH.hash, beneficiaryInputAmount ) ) val amountToWidthdraw = redeemer.amount val outputs = List( makePubKeyHashOutput( beneficiaryPKH.hash, amountToWidthdraw ), TxOut( address = Address(ScriptCredential(contractHash), Option.None), value = Value.lovelace(vestingDatum.initialAmount - amountToWidthdraw), datum = OutputDatum.OutputDatum(vestingDatum.toData) ) ) val txInfo = TxInfo( inputs = inputs, id = random[TxId], signatories = signatories, outputs = outputs, validRange = interval, fee = fee ) val scriptContext = ScriptContext( txInfo = txInfo, redeemer = toData(redeemer), scriptInfo = ScriptInfo.SpendingScript( txOutRef = inputs.head.outRef, datum = Some(vestingDatum.toData) ) ) // debugPrint(txInfo, vestingDatum, redeemer) compiled.runScript(scriptContext) } // Success cases test("Successful full withdrawal at/after vesting period ends") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration) val redeemer = Action(vestingDatum.initialAmount) val result = checkTestCase( TestCase( signatories = signatories, interval = interval, vestingDatum = vestingDatum, redeemer = redeemer, beneficiaryInputAmount = defaultFee // Benefitiary paid the fee ) ) // println(result) assert(result.isSuccess, "Script execution should succeed") } test("Successful partial 50% withdrawal") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration / 2) val redeemer = Action(vestingDatum.initialAmount / 2) val result = checkTestCase( TestCase( signatories = signatories, interval = interval, vestingDatum = vestingDatum, redeemer = redeemer, beneficiaryInputAmount = defaultFee ) ) // println(result) assert(result.isSuccess, "Script execution should succeed for partial withdrawal") } test("Successful Partial withdrawal at 25% of vesting period") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) // 25% of vesting period val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration / 4) val withdrawalAmount = vestingDatum.initialAmount / 4 val redeemer = Action(withdrawalAmount) val result = checkTestCase( TestCase( signatories = signatories, interval = interval, vestingDatum = vestingDatum, redeemer = redeemer, beneficiaryInputAmount = defaultFee ) ) assert(result.isSuccess, "Partial withdrawal should succeed at 25% of vesting period") } test("Successful Withdrawal right after vesting starts (should get minimal amount)") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = BigInt(31_536_000L) // This ensures at least 1 lovelace per second ) val signatories = List(beneficiaryPKH) // 1 second after val interval = Interval.after(vestingDatum.startTimestamp + 1000) val withdrawalAmount = vestingDatum.initialAmount * 1000 / vestingDatum.duration val redeemer = Action(withdrawalAmount) val result = checkTestCase( TestCase( signatories = signatories, interval = interval, vestingDatum = vestingDatum, redeemer = redeemer, beneficiaryInputAmount = defaultFee ) ) assert(withdrawalAmount > 0, "Withdrawal amount should be greater than 0") assert(result.isSuccess, "Minimal withdrawal should succeed") } test("Successful Withdrawal with very large vesting duration") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = BigInt(315360000000000L), // 10 000 years initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) // 1 year after start val interval = Interval.after(vestingDatum.startTimestamp + BigInt(31536000000L)) val expectedAmount = (vestingDatum.initialAmount * BigInt(31536000000L)) / vestingDatum.duration val redeemer = Action(expectedAmount) val result = checkTestCase( TestCase( signatories = signatories, interval = interval, vestingDatum = vestingDatum, redeemer = redeemer, beneficiaryInputAmount = defaultFee ) ) assert( result.isSuccess, "Partial withdrawal should succeed with very large vesting duration" ) } test("Successful Withdrawal with very small vesting amount") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = BigInt(1000) ) val signatories = List(beneficiaryPKH) val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration) val redeemer = Action(BigInt(1000)) val result = checkTestCase( TestCase( signatories = signatories, interval = interval, vestingDatum = vestingDatum, redeemer = redeemer, beneficiaryInputAmount = defaultFee ) ) assert(result.isSuccess, "Full withdrawal should succeed with small vesting amount") } test("Successful Multiple partial withdrawals.") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) // Set time to 75% of vesting period val interval = Interval.after(vestingDatum.startTimestamp + (vestingDatum.duration * 3) / 4) // Simulate that 25% was already withdrawn val remainingInContract = (vestingDatum.initialAmount * 3) / 4 val alreadyVested = (vestingDatum.initialAmount * 3) / 4 val alreadyWithdrawn = vestingDatum.initialAmount / 4 val availableForWithdrawal = alreadyVested - alreadyWithdrawn val redeemer = Action(availableForWithdrawal) val contractInput = makeScriptHashInput(contractHash, remainingInContract) val beneficiaryInput = makePubKeyHashInput(beneficiaryPKH.hash, defaultFee) val inputs = List(contractInput, beneficiaryInput) val beneficiaryOutputAmount = availableForWithdrawal val beneficiaryOutput = makePubKeyHashOutput(beneficiaryPKH.hash, beneficiaryOutputAmount) val contractOutput = TxOut( address = Address(ScriptCredential(contractHash), Option.None), value = Value.lovelace(remainingInContract - availableForWithdrawal), datum = OutputDatum.OutputDatum(vestingDatum.toData) ) val outputs = List(beneficiaryOutput, contractOutput) val txInfo = TxInfo( inputs = inputs, id = random[TxId], signatories = signatories, outputs = outputs, validRange = interval, fee = defaultFee ) val scriptContext = ScriptContext( txInfo = txInfo, redeemer = toData(redeemer), scriptInfo = ScriptInfo.SpendingScript( txOutRef = inputs.head.outRef, datum = Some(vestingDatum.toData) ) ) val result = compiled.scriptV3().runWithProfileReport(scriptContext) assert( result.isSuccess, "Second partial withdrawal should succeed at 75% of vesting period" ) } // Fail cases test("Fail Full withdrawal before vesting period ends") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration / 2) val redeemer = Action(vestingDatum.initialAmount) val result = checkTestCase( TestCase( signatories = signatories, interval = interval, vestingDatum = vestingDatum, redeemer = redeemer, beneficiaryInputAmount = defaultFee ) ) // println(result) assert(result.isFailure, "Script execution should fail before the vesting period ends") } test("Fail 50% Withdrawal which exceeds available 25%") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) // 25% of vesting period val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration / 4) val excessiveAmount = vestingDatum.initialAmount / 2 val redeemer = Action(excessiveAmount) val result = checkTestCase( TestCase( signatories = signatories, interval = interval, vestingDatum = vestingDatum, redeemer = redeemer, beneficiaryInputAmount = defaultFee ) ) assert( result.isFailure, "Withdrawal should fail when amount exceeds available vested amount" ) } test("Fail Withdrawal with no beneficiary signature") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(ownerPKH) val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration) val redeemer = Action(vestingDatum.initialAmount) val result = checkTestCase( TestCase( signatories = signatories, interval = interval, vestingDatum = vestingDatum, redeemer = redeemer, beneficiaryInputAmount = defaultFee ) ) // println(result) assert(result.isFailure, "Script execution should fail because of no signatures") } test("Fail Withdrawal because no fee is paid") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration) val redeemer = Action(vestingDatum.initialAmount) val result = checkTestCase( TestCase( signatories = signatories, interval = interval, vestingDatum = vestingDatum, redeemer = redeemer, beneficiaryInputAmount = 0 ) ) // println(result) assert(result.isFailure, "Script execution should fail because no fee is paid") } test("Fail: Withdrawal amount is zero") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration) val redeemer = Action(BigInt(0)) val result = checkTestCase( TestCase( signatories = signatories, interval = interval, vestingDatum = vestingDatum, redeemer = redeemer, beneficiaryInputAmount = defaultFee ) ) assert(result.isFailure, "Withdrawal should fail when amount is zero") } test("2 ScriptContexts") { val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) // 25% of vesting period val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration / 4) val withdrawalAmount = vestingDatum.initialAmount / 4 val redeemer = Action(withdrawalAmount) val inputs = List( makeScriptHashInput( contractHash, vestingDatum.initialAmount ), makePubKeyHashInput( beneficiaryPKH.hash, defaultFee ) ) val outputs = List( makePubKeyHashOutput( beneficiaryPKH.hash, withdrawalAmount ), makePubKeyHashOutput( beneficiaryPKH.hash, withdrawalAmount ), TxOut( address = Address(ScriptCredential(contractHash), Option.None), value = Value.lovelace(vestingDatum.initialAmount - withdrawalAmount), datum = OutputDatum.OutputDatum(vestingDatum.toData) ) ) val txInfo = TxInfo( inputs = inputs, id = random[TxId], signatories = signatories, outputs = outputs, validRange = interval, fee = defaultFee ) val scriptContext = ScriptContext( txInfo = txInfo, redeemer = toData(redeemer), scriptInfo = ScriptInfo.SpendingScript( txOutRef = inputs.head.outRef, datum = Some(vestingDatum.toData) ) ) val scriptContext2 = ScriptContext( txInfo = txInfo, redeemer = toData(redeemer), scriptInfo = ScriptInfo.SpendingScript( txOutRef = inputs.tail.head.outRef, datum = Some(vestingDatum.toData) ) ) // debugPrint(txInfo, vestingDatum, redeemer) val firstResult = compiled.runScript(scriptContext) assert(firstResult.isFailure, "First withdrawal should succeed") val secondResult = compiled.runScript(scriptContext2) assert(secondResult.isFailure, "Second withdrawal should fail") } test("Fail: double satisfaction across two vesting inputs sharing one continuing output") { // Two independent vesting UTxOs with identical config, each holding the full amount. // Spent together, a single continuing output of (amount - withdrawn) satisfies BOTH // validator runs, letting the spender pocket the second UTxO's locked funds. val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) // 50% of the vesting period -> 50% (10 ADA) available per input val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration / 2) val withdrawalAmount = vestingDatum.initialAmount / 2 val redeemer = Action(withdrawalAmount) val scriptInput1 = makeScriptHashInput(contractHash, vestingDatum.initialAmount) val scriptInput2 = makeScriptHashInput(contractHash, vestingDatum.initialAmount) val beneficiaryInput = makePubKeyHashInput(beneficiaryPKH.hash, defaultFee) val inputs = List(scriptInput1, scriptInput2, beneficiaryInput) // Only ONE continuing output and one beneficiary output: the rest is siphoned. val outputs = List( makePubKeyHashOutput(beneficiaryPKH.hash, withdrawalAmount), TxOut( address = Address(ScriptCredential(contractHash), Option.None), value = Value.lovelace(vestingDatum.initialAmount - withdrawalAmount), datum = OutputDatum.OutputDatum(vestingDatum.toData) ) ) val txInfo = TxInfo( inputs = inputs, id = random[TxId], signatories = signatories, outputs = outputs, validRange = interval, fee = defaultFee ) def contextFor(ref: TxOutRef): ScriptContext = ScriptContext( txInfo = txInfo, redeemer = toData(redeemer), scriptInfo = ScriptInfo.SpendingScript( txOutRef = ref, datum = Some(vestingDatum.toData) ) ) val firstResult = compiled.runScript(contextFor(scriptInput1.outRef)) val secondResult = compiled.runScript(contextFor(scriptInput2.outRef)) assert( firstResult.isFailure, "Spending a vesting input must fail when 2+ vesting inputs are present" ) assert( secondResult.isFailure, "Spending a vesting input must fail when 2+ vesting inputs are present" ) } test("Fail: native tokens stripped from the continuing output") { // The vesting UTxO holds ADA plus a native token. A partial withdrawal preserves the // ADA remainder but routes the native token to the spender. A lovelace-only continuing // output check would accept this; full value preservation must reject it. val tokenPolicy = TestUtil.mockScriptHash(1) val tokenName = hex"abcd" val nativeToken = Value(tokenPolicy, tokenName, BigInt(100)) val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration / 2) val withdrawalAmount = vestingDatum.initialAmount / 2 val redeemer = Action(withdrawalAmount) val scriptInput = TxInInfo( outRef = TxOutRef(random[TxId], 0), resolved = TxOut( address = Address(ScriptCredential(contractHash), Option.None), value = Value.lovelace(vestingDatum.initialAmount) + nativeToken ) ) val beneficiaryInput = makePubKeyHashInput(beneficiaryPKH.hash, defaultFee) val inputs = List(scriptInput, beneficiaryInput) val outputs = List( makePubKeyHashOutput(beneficiaryPKH.hash, withdrawalAmount), TxOut( address = Address(ScriptCredential(contractHash), Option.None), // native token omitted -> stolen value = Value.lovelace(vestingDatum.initialAmount - withdrawalAmount), datum = OutputDatum.OutputDatum(vestingDatum.toData) ) ) val txInfo = TxInfo( inputs = inputs, id = random[TxId], signatories = signatories, outputs = outputs, validRange = interval, fee = defaultFee ) val scriptContext = ScriptContext( txInfo = txInfo, redeemer = toData(redeemer), scriptInfo = ScriptInfo.SpendingScript( txOutRef = scriptInput.outRef, datum = Some(vestingDatum.toData) ) ) val result = compiled.runScript(scriptContext) assert(result.isFailure, "Stripping native tokens from the continuing output must fail") } test("Fail: continuing output redirects the staking credential") { // The continuing output keeps the script payment credential, value and datum, but // attaches the attacker's staking credential — hijacking delegation rewards. Matching // on payment credential alone would accept it; the full address must be preserved. val vestingDatum = Config( beneficiary = beneficiaryPKH, startTimestamp = defaultStartTime, duration = defaultDuration, initialAmount = defaultInitialAmount ) val signatories = List(beneficiaryPKH) val interval = Interval.after(vestingDatum.startTimestamp + vestingDatum.duration / 2) val withdrawalAmount = vestingDatum.initialAmount / 2 val redeemer = Action(withdrawalAmount) val scriptInput = makeScriptHashInput(contractHash, vestingDatum.initialAmount) val beneficiaryInput = makePubKeyHashInput(beneficiaryPKH.hash, defaultFee) val inputs = List(scriptInput, beneficiaryInput) val hijackedAddress = Address( ScriptCredential(contractHash), Option.Some(StakingCredential.StakingHash(Credential.PubKeyCredential(ownerPKH))) ) val outputs = List( makePubKeyHashOutput(beneficiaryPKH.hash, withdrawalAmount), TxOut( address = hijackedAddress, value = Value.lovelace(vestingDatum.initialAmount - withdrawalAmount), datum = OutputDatum.OutputDatum(vestingDatum.toData) ) ) val txInfo = TxInfo( inputs = inputs, id = random[TxId], signatories = signatories, outputs = outputs, validRange = interval, fee = defaultFee ) val scriptContext = ScriptContext( txInfo = txInfo, redeemer = toData(redeemer), scriptInfo = ScriptInfo.SpendingScript( txOutRef = scriptInput.outRef, datum = Some(vestingDatum.toData) ) ) val result = compiled.runScript(scriptContext) assert(result.isFailure, "Redirecting the staking credential must fail") } } ```