Skip to content

How to Test a Contract


Before deploying a contract on-chain, it is necessary to test every execution path thoroughly. This section introduces local testing methods using the tools provided by UTXO_Compiler, along with practical suggestions for organizing test cases.


Testing Strategy Overview

Testing UTXO contracts is different from testing Ethereum contracts. Because contract logic ultimately runs as a locking script executed by nodes, the main goals of local testing are:

  1. Verify normal paths: valid inputs should make the contract return true (spending is allowed)
  2. Verify rejection paths: invalid inputs should be rejected by the contract (return false or terminate directly)
  3. Verify boundary conditions: values exactly on condition boundaries
  4. Verify public execution flow: multiple public functions run serially, and mutually exclusive paths should be tested separately through a path dispatch parameter
  5. Verify bytecode output: the compilation result matches the expected opcode sequence
  6. Verify transaction context: contracts using fields such as BVM.unlockingInput and BVM.outputsHash must confirm that local simulated data matches the real transaction construction

It is recommended to split tests into three layers:

LayerGoalCommon Tools
CompilationSyntax, types, ownership, import paths, and bytecode generation passutxo_compiler
ExecutionPublic entries, private function calls, branches, loops, and stack state behave as expected--debug debugger
TransactionParent transactions, current transaction outputs, signatures, and state advancement match on-chain rulesDebugger settxfile + transaction construction tools

Method 1: Compiler Smoke Test

The most basic test is to compile the contract file directly and confirm there are no syntax, type, or ownership errors:

bash
./utxo_compiler my_contract.ct

If my_contract.json is generated with no error, the contract is at least correct at the syntax, type, ownership, and bytecode generation layers. The output JSON is written to the current working directory by default, using the source file stem as the filename; for example, compiling contracts/p2pkh.ct generates p2pkh.json.

Check these fields in particular:

FieldWhat to Check
lock.hexFinal locking script hex, placed into the transaction output's locking script during deployment
lock.asmHuman-readable disassembly, useful for checking EqualVerify, CheckSig, jumps, and return positions
abiPublic function call parameters, used to verify unlocking script parameter order
functionsFull function information, used by the debugger and regression checks
structsStruct field order, especially important for transaction-level inputs such as PreTX / CurrentTX

If you maintain multiple contracts, use a batch script for quick smoke checks:

bash
#!/usr/bin/env bash
set -u
shopt -s globstar nullglob

COMPILER="${COMPILER:-./utxo_compiler}"
ROOT="${1:-contracts}"
PASS=0
FAIL=0

for f in "$ROOT"/**/*.ct; do
    if output=$("$COMPILER" "$f" 2>&1); then
        echo "PASS: $f"
        ((PASS++))
    else
        echo "FAIL: $f"
        echo "$output" | sed 's/^/  /'
        ((FAIL++))
    fi
done

echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]

For example contracts, consider splitting them into directories by capability: basic syntax, function calls, arrays and structs, built-in functions, transaction context, and complete contract examples. When compiling them, run the basic syntax directory first, then complete contract examples. Complex transaction-level contracts usually need additional transaction context; compilation success alone does not mean the spending path is correct.


Method 2: Manual Testing with the Debugger

The built-in debugger is currently the most direct execution verification tool. Start the debugger, enter a set of parameters, run the contract to completion, and inspect the final stack, stop reason, and important breakpoint locations:

bash
./utxo_compiler my_contract.ct --debug

The debugger first compiles the contract implicitly with debug information enabled, then prompts you to select a debuggable function. Entering a number debugs a single function; pressing Enter directly asks for parameters for all public functions in declaration order, simulating the full public execution flow.

Common commands:

CommandPurpose
run / rStart execution
break <line or function>Set a breakpoint at a source line or function entry
step / next / finishStep into, step over, or step out of the current function
stackInspect the main stack and alt stack, confirming parameters, temporary values, and returns
bytecode [N]View bytecode and source line numbers near the current PC
backtraceView the current function call stack

Test Matrix

For each public entry or path branch, prepare test inputs using this matrix:

ScenarioExpected Result
Correct signature + matching public keyStack top = 1, pass
Wrong signatureExecution terminates or stack top = 0
Public key hash mismatchEqualVerify fails and terminates
Empty data / zero valueDepends on contract logic; must not accidentally bypass checks
Boundary value, such as time exactly equal to the deadlineVerify >= / > semantics
Wrong pathEnters the expected rejection path, or returns false
Wrong input order for multiple public functionsFails at the first location that depends on the wrong parameter

Method 3: Writing Test Contract File Sets

For contract collections that need continuous checking, organize test cases as independent .ct files under tests/, and split them by capability boundary:

Case TypeSuggested Location
Valid compilation casestests/valid/*.ct
Expected compilation failurestests/invalid/*.ct, with the script reversing the exit-code expectation
Debugger regression casestests/debugger/*.ct, with notes for breakpoints and expected stack state
Transaction context casestests/tx/*.ct + tests/tx/*.txt, one contract per settxfile input

Positive cases can use the batch script from the previous section. For expected-failure cases, write a separate reverse-check script:

bash
#!/usr/bin/env bash
set -u
shopt -s globstar nullglob

COMPILER="${COMPILER:-./utxo_compiler}"
PASS=0
FAIL=0

for f in tests/invalid/**/*.ct; do
    if output=$("$COMPILER" "$f" 2>&1); then
        echo "FAIL: $f"
        echo "  expected compilation to fail"
        ((FAIL++))
    else
        echo "PASS: $f"
        ((PASS++))
    fi
done

echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]

For expected-failure cases, do not only check that "it failed." During review, confirm that the error type is the one you intended to cover, such as ownership errors, type mismatches, illegal Range() parameters, or invalid sub-scope alt-stack operations.


Method 4: Debug Info-Assisted Testing

Compile with the -d flag to generate debug information, then use the debugger to inspect each line's execution:

bash
./utxo_compiler my_contract.ct -d

Debug information contains source-line-to-bytecode-offset mappings, which can be used to:

  • Confirm whether a block of code was executed
  • Check whether loop unrolling and jump positions are correct
  • Verify that PC mappings for conditional branches are stable
  • Check whether function symbols, parameters, and local variable information are complete

To specify the debug info filename, use --debug-output together with -d:

bash
./utxo_compiler my_contract.ct -d --debug-output my_contract.debug

If a contract uses SetAlt / SetMain inside private functions or if/else sub-scopes, the compiler blocks this by default to avoid implicit alt-stack order corruption. After confirming that your alt-stack protocol is correct, explicitly enable it:

bash
./utxo_compiler my_contract.ct --asa

--asa is short for --allow-subscope-altstack. Only enable it when you really need to manage the alt stack across sub-scopes, and add debugger tests for the related branches.


Method 5: Transaction Context Testing

Contracts that use BVM.* fields cannot be tested with ordinary parameters alone. A stateful contract such as Counter usually needs to prove:

  1. The current input spends the correct old contract UTXO.
  2. The parent transaction txid can be reconstructed from the provided PreTX data.
  3. The current transaction output hash matches BVM.outputsHash.
  4. The state value in the new contract output advances according to the rules.

The debugger provides settxfile for loading transaction context. The file uses one key-value pair per line:

text
version: 02000000
locktime: 00000000
inputCount: 01000000
outputCount: 02000000
inputsHash: a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef
unlockingInput: 02ff01ab
outputsHash: 0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff

Load and inspect it in the debugger:

text
(debug) settxfile tests/tx/counter_context.txt
(debug) showtx
(debug) reset
(debug) run

Transaction context tests should cover at least:

ScenarioCheckpoint
Correct parent transactionEqualVerify(txid, BVM.unlockingInput.Slice(...)) passes
Parent transaction output tamperedParent transaction txid reconstruction fails
Correct current outputBVM.outputsHash verification passes
Current output state not advancedState increment check fails
Current output count or order changedOutput hash or struct field check fails

For more settxfile keys and format details, see the Debugger User Manual.


How to Judge Test Results

The compiler and debugger use slightly different pass criteria:

StagePass CriteriaFailure Signals
CompilationGenerates .json with no errorsSyntax errors, type errors, ownership errors, import failures
Bytecode reviewlock.asm matches the expected opcode sequenceParameter order, hash order, or Verify operation position is unexpected
Debug executionFinal stack top is true, or all key EqualVerify checks passEqualVerify / CheckSigVerify terminates, or stack top is 0
Transaction-level verificationContext such as BVM.unlockingInput and BVM.outputsHash matches the constructed transactionParent transaction txid reconstruction fails, output hash mismatches, state advancement is wrong

For rejection paths, a passing test does not mean "execution finished"; it means "execution failed at the expected location." If a wrong signature terminates at CheckSigVerify, or a wrong parent transaction terminates at txid verification, that is a valid rejection-path test.


Beginner Pitfalls

Pitfall 1: Treating ownership errors as runtime errors

Ownership checks happen at compile time: the compiler reports them during the utxo_compiler phase. During testing, confirm that the compilation failure location and error type match expectations. You do not need debugger inputs for "runtime ownership errors."

Pitfall 2: EqualVerify failure does not return false; it terminates

EqualVerify, CheckSigVerify, and other *Verify functions terminate execution directly when they fail, equivalent to script execution failure. In rejection tests, these failures do not leave 0 on the stack; they abort the whole execution. The debugger shows that execution was terminated.

Pitfall 3: Contract member variables are part of the bytecode

When testing different instance data for the same contract logic, do not only change unlocking script parameters. self.X, constructor-style parameters, or locking script suffix data affect lock.hex, so you need to recompile or replace instance data inside the locking script.

Pitfall 4: Loop count is a compile-time constant

The parameters to Range() must be values determinable at compile time, such as literals or constant expressions. Runtime variables cannot control loop counts. To cover different loop sizes, write different test contracts or different constant configurations.

Pitfall 5: Imported library helpers are not standalone call targets

Library functions such as verifyP2PKH must be tested through the public contract function that calls them. They do not appear as independent ABI entries and do not need separate unlocking parameters.

Pitfall 6: Parameter order for multiple public functions is easy to reverse

Multiple public functions execute continuously in declaration order. Unlocking script push order must align with the ABI and execution order. To express mutually exclusive call paths, prefer a single public entry with a path dispatch parameter.


Quick Review

  • Contract testing should cover compilation, debugger execution, and transaction-context verification.
  • A rejection-path test passes when it fails at the expected point, not necessarily when execution reaches the end.
  • Ownership errors happen at compile time; EqualVerify and CheckSigVerify failures terminate the script.
  • Stateful contracts need focused tests for parent transactions, current outputs, state progression, and output order.
  • Test data must match the ABI, struct field order, and fixed-array lengths.

Next Steps


🇨🇳 中文版