如何测试合约
在将合约部署到链上之前,充分测试所有执行路径是必要的。本节介绍用 UTXO_Compiler 提供的工具进行本地测试的方法,以及组织测试用例的实践建议。
测试策略概览
UTXO 合约的测试重点与以太坊合约有所不同。由于合约逻辑最终会作为锁定脚本被节点执行,本地测试的核心目标是:
- 验证正常路径:合法输入应使合约返回真(花费被允许)
- 验证拒绝路径:不合法输入应被合约拒绝(返回假或直接终止)
- 验证边界条件:恰好在条件边界上的值
- 验证公有执行流程:多个公有函数会串行执行,互斥路径应通过
path分发参数分别测试 - 验证字节码输出:编译结果符合预期的操作码序列
- 验证交易上下文:使用
BVM.unlockingInput、BVM.outputsHash等字段的合约,要确认本地模拟数据与真实交易构造一致
推荐把测试分成三层:
| 层级 | 目标 | 常用工具 |
|---|---|---|
| 编译级 | 语法、类型、所有权、导入路径、字节码生成是否通过 | utxo_compiler |
| 执行级 | 公有入口、私有函数调用、分支、循环、栈状态是否符合预期 | --debug 调试器 |
| 交易级 | 父交易、当前交易输出、签名、状态推进是否匹配链上规则 | 调试器 settxfile + 交易构造工具 |
方法一:编译器冒烟测试
最基础的测试是直接让编译器编译合约文件,确认没有语法错误、类型错误和所有权错误:
./utxo_compiler my_contract.ct如果生成 my_contract.json 且没有报错,说明合约至少在语法、类型、所有权和字节码生成层面是正确的。输出 JSON 默认写到当前工作目录,文件名取源文件的 stem;例如编译 contracts/p2pkh.ct 会生成 p2pkh.json。
重点检查这些字段:
| 字段 | 检查点 |
|---|---|
lock.hex | 最终锁定脚本十六进制,部署时会放进 UTXO 的 locking script |
lock.asm | 便于人工审查的反汇编,适合核对 EqualVerify、CheckSig、跳转和返回位置 |
abi | 公有函数的调用参数,用来核对 unlocking script 的参数顺序 |
functions | 全部函数信息,调试器和回归检查会用到 |
structs | 结构体字段顺序,交易级合约输入 PreTX / CurrentTX 时尤其重要 |
如果你维护多份合约,建议用批处理脚本做快速冒烟检查:
#!/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 ]建议把示例合约按能力拆分到不同目录,例如基础语法、函数调用、数组和结构体、内置函数、交易上下文和完整合约示例。编译这些示例时,可以先跑基础语法目录,再跑完整合约示例;复杂交易级合约通常需要补充交易上下文,单纯编译通过还不等于花费路径正确。
方法二:调试器手动测试
内置调试器 是目前最直接的执行验证手段。启动调试器,输入一组参数,让合约运行到结束,观察最终栈、执行停止原因和关键断点位置:
./utxo_compiler my_contract.ct --debug调试器会先隐式编译合约并启用调试信息,然后提示选择可调试函数。输入编号会单独调试某个函数;直接回车会按声明顺序为所有公有函数输入参数,用来模拟完整公有执行流程。
常用命令:
| 命令 | 用途 |
|---|---|
run / r | 开始执行 |
break <行号或函数名> | 在源码行或函数入口设置断点 |
step / next / finish | 单步进入、单步跳过、跳出当前函数 |
stack | 查看主栈和副栈,确认参数、临时值和返回值 |
bytecode [N] | 查看当前 PC 附近的字节码和源码行号 |
backtrace | 查看当前函数调用栈 |
测试矩阵
对于每个公有入口或 path 分支,建议按以下矩阵准备测试输入:
| 场景 | 预期结果 |
|---|---|
| 正确的签名 + 匹配的公钥 | 栈顶 = 1,通过 |
| 错误的签名 | 执行终止或栈顶 = 0 |
| 公钥哈希不匹配 | EqualVerify 失败,终止 |
| 空数据 / 零值 | 视合约逻辑而定,不能意外绕过校验 |
| 边界值(如时间刚好等于截止时间) | 验证 >= / > 的语义 |
错误的 path | 进入预期拒绝路径,或返回假 |
| 多公有函数输入顺序错误 | 在第一个依赖错误参数的位置失败 |
方法三:编写测试合约文件集
对于需要持续检查的合约集合,建议将测试用例组织为独立的 .ct 文件,放在 tests/ 目录下,并按能力边界拆分:
| 用例类型 | 建议放置方式 |
|---|---|
| 正常编译用例 | tests/valid/*.ct |
| 预期编译失败用例 | tests/invalid/*.ct,在脚本中反向判断退出码 |
| 调试器回归用例 | tests/debugger/*.ct,配套记录要设置的断点和期望栈状态 |
| 交易上下文用例 | tests/tx/*.ct + tests/tx/*.txt,合约和 settxfile 输入一一对应 |
正向用例可以使用上一节的批量脚本。对于预期失败的用例,可单独写一个反向检查脚本:
#!/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 ]预期失败用例不要只检查“失败了”,还要在人工审查时确认错误类型是你想覆盖的类型,例如所有权错误、类型不匹配、非法 Range() 参数或非法子作用域副栈操作。
方法四:调试信息辅助测试
使用 -d 标志编译,生成调试信息文件,然后配合调试器检查每一行的执行情况:
./utxo_compiler my_contract.ct -d调试信息包含源码行号到字节码偏移的映射,可以用来:
- 确认某段代码是否被执行
- 检查循环展开和跳转位置是否正确
- 验证条件分支的 PC 映射是否稳定
- 检查函数符号、参数和局部变量信息是否完整
如果要指定调试信息文件名,请把 --debug-output 与 -d 一起使用:
./utxo_compiler my_contract.ct -d --debug-output my_contract.debug如果合约在私有函数或 if/else 子作用域中使用 SetAlt / SetMain,编译器默认会拦截这类写法,以避免副栈顺序被隐式破坏。确认你的副栈协议正确后,可显式开启:
./utxo_compiler my_contract.ct --asa--asa 是 --allow-subscope-altstack 的短写。建议只在确实需要跨子作用域管理副栈时开启,并为相关分支补充调试器测试。
方法五:交易上下文测试
使用 BVM.* 字段的合约不能只靠普通参数测试。例如 Counter 这类状态合约通常需要证明:
- 当前输入花费的是正确的旧合约 UTXO。
- 父交易 txid 能由提供的
PreTX数据重建出来。 - 当前交易输出的哈希与
BVM.outputsHash一致。 - 新合约输出中的状态值按规则推进。
调试器提供 settxfile 用来加载交易上下文。文件采用每行一个键值对的文本格式:
version: 02000000
locktime: 00000000
inputCount: 01000000
outputCount: 02000000
inputsHash: a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef
unlockingInput: 02ff01ab
outputsHash: 0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff在调试器中加载并查看:
(debug) settxfile tests/tx/counter_context.txt
(debug) showtx
(debug) reset
(debug) run交易上下文测试建议至少覆盖:
| 场景 | 检查点 |
|---|---|
| 父交易正确 | EqualVerify(txid, BVM.unlockingInput.Slice(...)) 通过 |
| 父交易输出被篡改 | 父交易 txid 重建失败 |
| 当前输出正确 | BVM.outputsHash 校验通过 |
| 当前输出状态未推进 | 状态增量校验失败 |
| 当前输出数量或顺序变化 | 输出哈希或结构体字段校验失败 |
更多 settxfile 键名和格式说明见 调试器用户使用手册。
如何判断测试结果
编译器和调试器关注的通过标准略有不同:
| 阶段 | 通过标准 | 失败信号 |
|---|---|---|
| 编译 | 生成 .json,无错误日志 | 语法错误、类型错误、所有权错误、导入失败 |
| 字节码审查 | lock.asm 与预期操作码一致 | 参数顺序、哈希顺序、Verify 操作位置不符合预期 |
| 调试执行 | 最终栈顶为真,或关键 EqualVerify 全部通过 | EqualVerify / CheckSigVerify 终止,或栈顶为 0 |
| 交易级验证 | BVM.unlockingInput、BVM.outputsHash 等上下文与构造交易匹配 | 父交易 txid 重建失败、输出哈希不一致、状态推进错误 |
对于拒绝路径,测试通过的标志不是“运行结束”,而是“在预期位置失败”。如果错误签名在 CheckSigVerify 处终止、错误父交易在 txid 校验处终止,这就是有效的拒绝路径测试。
初级开发者易踩坑
陷阱一:把所有权错误当成运行时错误
所有权检查是编译期的——编译器会在 utxo_compiler 阶段报错。测试时只需确认编译失败位置和错误类型符合预期,不需要为“运行时所有权错误”准备调试器输入。
陷阱二:EqualVerify 失败不返回假,而是终止
EqualVerify、CheckSigVerify 等 *Verify 函数在失败时直接终止执行(等价于脚本执行失败)。测试拒绝场景时,这类函数触发的失败不会在栈上留下 0,而是让整个执行中止。调试器中会显示执行被终止。
陷阱三:合约成员变量是字节码的一部分
测试同一个合约逻辑的不同实例数据时,不能只修改 unlocking script 参数。self.X、构造参数或锁定脚本后缀数据会影响 lock.hex,需要重新编译或替换锁定脚本中的实例数据。
陷阱四:循环次数是编译期常量
Range() 的参数必须是编译期可确定的值(字面量或常量表达式)。不能用运行时变量控制循环次数;需要覆盖不同循环规模时,应写成不同测试合约或不同常量配置。
陷阱五:导入库函数不是独立调用目标
verifyP2PKH 这类库函数需要通过调用它的合约公有函数来测试。它们不会作为独立 ABI 入口出现,也不需要单独提供解锁参数。
陷阱六:多公有函数的参数顺序容易反
多个公有函数会按声明顺序连续执行。解锁参数的压栈顺序必须与 ABI 和执行顺序配合;如果要表达互斥调用路径,优先使用单个公有入口加 path 参数分发。
快速回顾
- 合约测试至少分为编译测试、调试执行测试和交易上下文测试。
- “拒绝路径”测试成功的标志,是在预期位置失败,而不是一定要正常运行结束。
- 所有权错误发生在编译期;
EqualVerify、CheckSigVerify失败会直接终止脚本。 - 有状态合约需要重点测试父交易、当前输出、状态推进和输出顺序。
- 测试数据要和 ABI、结构体字段顺序、固定数组长度保持一致。
下一步
- 如何调试合约 — 用交互式调试器深入检查执行过程