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:
- Verify the script passes validation with the provided redeemer
- 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 defaultThe 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.