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

Advanced Features

Testing Validators with Custom Evaluators

One of the challenges we encounter when testing Cardano smart contracts is testing negative cases-scenarios where your validator should reject a transaction. TxBuilder’s enables the developer to do it via custom evaluators.

The Problem with Negative Testing

When building a transaction with scripts, TxBuilder evaluates them during build() to:

  1. Verify the script passes validation with the provided redeemer
  2. Calculate execution costs to fill out the Redeemer

This creates a dilemma for negative testing:

// A redeemer that is known to fail with our validator val invalidRedeemer = Data(...) val tx = TxBuilder(env) .spend(scriptUtxo, invalidRedeemer, validator) .payTo(attacker, scriptUtxo.output.value) .build(changeTo = changeAddress) // Throws `PlutusScriptEvaluationError` by default

The validator correctly rejects the invalid redeemer during local evaluation, but this means build() throws an exception and you never get a transaction to test. You can’t submit it to verify the on-chain behavior.

The Solution: Constant Budget Evaluator

// Use constant budget evaluator for negative tests val tx = TxBuilder.withConstMaxBudgetEvaluator(env) .spend(scriptUtxo, invalidRedeemer, validator) .payTo(attacker, scriptUtxo.output.value) .build(changeTo = changeAddress) // The previous exception is gone, as the scripts were never actually evaluated .sign(signer) .transaction // Now you can submit and verify it fails on-chain import scalus.utils.await import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration.* provider.submit(tx).await(30.seconds) match { case Left(error) => assert(error.message.contains("MyValidator logic error")) case Right(_) => fail("Transaction should have been rejected!") }

This technique bridges unit testing (validator in isolation) and integration testing (full transaction flow including on-chain rejection).

Testing Pattern: Positive and Negative Cases

Here’s a complete testing pattern using both evaluators:

import scalus.utils.await import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration.* class ValidatorIntegrationTest extends AnyFunSuite { val defaultEvaluator = PlutusScriptEvaluator( cardanoInfo, EvaluatorMode.EvaluateAndComputeCost ) test("valid redeemer is accepted on-chain") { val validRedeemer = Action.Reveal(correctPreimage) // Use default evaluator - should pass locally val tx = TxBuilder(env, defaultEvaluator) .spend(collateralUtxos) .spend(scriptUtxo, validRedeemer, validator, Set(receiverPkh)) .payTo(receiver, scriptUtxo.output.value) .build(changeTo = changeAddress) .sign(signer) .transaction // Should succeed on-chain provider.submit(tx).await(30.seconds) match { case Right(txHash) => println(s"Transaction accepted: ${txHash.toHex}") case Left(error) => fail(s"Valid transaction was rejected: $error") } } test("invalid redeemer is rejected on-chain") { val invalidRedeemer = Action.Reveal(wrongPreimage) // Use constant budget evaluator - bypasses local validation val tx = TxBuilder.withConstMaxBudgetEvaluator(env) .spend(collateralUtxos) .spend(scriptUtxo, invalidRedeemer, validator, Set(receiverPkh)) .payTo(attacker, scriptUtxo.output.value) .build(changeTo = changeAddress) .sign(signer) .transaction // Should fail on-chain provider.submit(tx).await(30.seconds) match { case Left(error) => assert(error.message.contains("preimage hash mismatch")) case Right(txHash) => fail(s"Invalid transaction was accepted: ${txHash.toHex}") } } }

Transaction Chaining

Transaction chaining allows building multiple transactions where each uses outputs from previous ones without waiting for on-chain confirmation. This is useful for complex workflows that require multiple sequential transactions.

Using Transaction.utxos

Every Transaction has a utxos method that returns the UTXOs it would create:

// Build the first transaction val lockTx = TxBuilder(env) .payTo(scriptAddress, Value.ada(10), lockDatum) .complete(emulator, sponsorAddress) .sign(signer) .transaction // Get UTXOs from lockTx directly (no provider query needed) val scriptUtxo = Utxo(lockTx.utxos.find(_._2.address == scriptAddress).get) // Build second transaction using the output from first val unlockTx = TxBuilder(env) .spend(scriptUtxo, unlockRedeemer, validator) .payTo(recipientAddress, scriptUtxo.output.value) .complete(emulator, sponsorAddress) .sign(signer) .transaction // Submit both transactions emulator.submit(lockTx) emulator.submit(unlockTx)

Emulator for Testing

The Emulator supports transaction chaining naturally by tracking UTXOs across submissions:

val emulator = Emulator(initialUtxos) // Submit first transaction emulator.submit(lockTx) // Can also query UTXOs from emulator val utxo = emulator.findUtxo( address = scriptAddress, transactionId = Some(lockTx.id) ).toOption.get // Submit second transaction emulator.submit(unlockTx)

The Transaction.utxos method is particularly useful when you need to reference specific outputs immediately after building a transaction, without querying a provider.

Last updated on