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:
- Verify normal paths: valid inputs should make the contract return true (spending is allowed)
- Verify rejection paths: invalid inputs should be rejected by the contract (return false or terminate directly)
- Verify boundary conditions: values exactly on condition boundaries
- Verify public execution flow: multiple public functions run serially, and mutually exclusive paths should be tested separately through a
pathdispatch parameter - Verify bytecode output: the compilation result matches the expected opcode sequence
- Verify transaction context: contracts using fields such as
BVM.unlockingInputandBVM.outputsHashmust confirm that local simulated data matches the real transaction construction
It is recommended to split tests into three layers:
| Layer | Goal | Common Tools |
|---|---|---|
| Compilation | Syntax, types, ownership, import paths, and bytecode generation pass | utxo_compiler |
| Execution | Public entries, private function calls, branches, loops, and stack state behave as expected | --debug debugger |
| Transaction | Parent transactions, current transaction outputs, signatures, and state advancement match on-chain rules | Debugger 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:
./utxo_compiler my_contract.ctIf 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:
| Field | What to Check |
|---|---|
lock.hex | Final locking script hex, placed into the transaction output's locking script during deployment |
lock.asm | Human-readable disassembly, useful for checking EqualVerify, CheckSig, jumps, and return positions |
abi | Public function call parameters, used to verify unlocking script parameter order |
functions | Full function information, used by the debugger and regression checks |
structs | Struct 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:
#!/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:
./utxo_compiler my_contract.ct --debugThe 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:
| Command | Purpose |
|---|---|
run / r | Start execution |
break <line or function> | Set a breakpoint at a source line or function entry |
step / next / finish | Step into, step over, or step out of the current function |
stack | Inspect the main stack and alt stack, confirming parameters, temporary values, and returns |
bytecode [N] | View bytecode and source line numbers near the current PC |
backtrace | View the current function call stack |
Test Matrix
For each public entry or path branch, prepare test inputs using this matrix:
| Scenario | Expected Result |
|---|---|
| Correct signature + matching public key | Stack top = 1, pass |
| Wrong signature | Execution terminates or stack top = 0 |
| Public key hash mismatch | EqualVerify fails and terminates |
| Empty data / zero value | Depends on contract logic; must not accidentally bypass checks |
| Boundary value, such as time exactly equal to the deadline | Verify >= / > semantics |
Wrong path | Enters the expected rejection path, or returns false |
| Wrong input order for multiple public functions | Fails 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 Type | Suggested Location |
|---|---|
| Valid compilation cases | tests/valid/*.ct |
| Expected compilation failures | tests/invalid/*.ct, with the script reversing the exit-code expectation |
| Debugger regression cases | tests/debugger/*.ct, with notes for breakpoints and expected stack state |
| Transaction context cases | tests/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:
#!/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:
./utxo_compiler my_contract.ct -dDebug 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:
./utxo_compiler my_contract.ct -d --debug-output my_contract.debugIf 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:
./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:
- The current input spends the correct old contract UTXO.
- The parent transaction txid can be reconstructed from the provided
PreTXdata. - The current transaction output hash matches
BVM.outputsHash. - 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:
version: 02000000
locktime: 00000000
inputCount: 01000000
outputCount: 02000000
inputsHash: a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef
unlockingInput: 02ff01ab
outputsHash: 0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffffLoad and inspect it in the debugger:
(debug) settxfile tests/tx/counter_context.txt
(debug) showtx
(debug) reset
(debug) runTransaction context tests should cover at least:
| Scenario | Checkpoint |
|---|---|
| Correct parent transaction | EqualVerify(txid, BVM.unlockingInput.Slice(...)) passes |
| Parent transaction output tampered | Parent transaction txid reconstruction fails |
| Correct current output | BVM.outputsHash verification passes |
| Current output state not advanced | State increment check fails |
| Current output count or order changed | Output 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:
| Stage | Pass Criteria | Failure Signals |
|---|---|---|
| Compilation | Generates .json with no errors | Syntax errors, type errors, ownership errors, import failures |
| Bytecode review | lock.asm matches the expected opcode sequence | Parameter order, hash order, or Verify operation position is unexpected |
| Debug execution | Final stack top is true, or all key EqualVerify checks pass | EqualVerify / CheckSigVerify terminates, or stack top is 0 |
| Transaction-level verification | Context such as BVM.unlockingInput and BVM.outputsHash matches the constructed transaction | Parent 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;
EqualVerifyandCheckSigVerifyfailures 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
- How to Debug a Contract — Deeply inspect execution with the interactive debugger