Skip to content

如何测试合约


在将合约部署到链上之前,充分测试所有执行路径是必要的。本节介绍用 UTXO_Compiler 提供的工具进行本地测试的方法,以及组织测试用例的实践建议。


测试策略概览

UTXO 合约的测试重点与以太坊合约有所不同。由于合约逻辑最终会作为锁定脚本被节点执行,本地测试的核心目标是:

  1. 验证正常路径:合法输入应使合约返回真(花费被允许)
  2. 验证拒绝路径:不合法输入应被合约拒绝(返回假或直接终止)
  3. 验证边界条件:恰好在条件边界上的值
  4. 验证公有执行流程:多个公有函数会串行执行,互斥路径应通过 path 分发参数分别测试
  5. 验证字节码输出:编译结果符合预期的操作码序列
  6. 验证交易上下文:使用 BVM.unlockingInputBVM.outputsHash 等字段的合约,要确认本地模拟数据与真实交易构造一致

推荐把测试分成三层:

层级目标常用工具
编译级语法、类型、所有权、导入路径、字节码生成是否通过utxo_compiler
执行级公有入口、私有函数调用、分支、循环、栈状态是否符合预期--debug 调试器
交易级父交易、当前交易输出、签名、状态推进是否匹配链上规则调试器 settxfile + 交易构造工具

方法一:编译器冒烟测试

最基础的测试是直接让编译器编译合约文件,确认没有语法错误、类型错误和所有权错误:

bash
./utxo_compiler my_contract.ct

如果生成 my_contract.json 且没有报错,说明合约至少在语法、类型、所有权和字节码生成层面是正确的。输出 JSON 默认写到当前工作目录,文件名取源文件的 stem;例如编译 contracts/p2pkh.ct 会生成 p2pkh.json

重点检查这些字段:

字段检查点
lock.hex最终锁定脚本十六进制,部署时会放进 UTXO 的 locking script
lock.asm便于人工审查的反汇编,适合核对 EqualVerifyCheckSig、跳转和返回位置
abi公有函数的调用参数,用来核对 unlocking script 的参数顺序
functions全部函数信息,调试器和回归检查会用到
structs结构体字段顺序,交易级合约输入 PreTX / CurrentTX 时尤其重要

如果你维护多份合约,建议用批处理脚本做快速冒烟检查:

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 ]

建议把示例合约按能力拆分到不同目录,例如基础语法、函数调用、数组和结构体、内置函数、交易上下文和完整合约示例。编译这些示例时,可以先跑基础语法目录,再跑完整合约示例;复杂交易级合约通常需要补充交易上下文,单纯编译通过还不等于花费路径正确。


方法二:调试器手动测试

内置调试器 是目前最直接的执行验证手段。启动调试器,输入一组参数,让合约运行到结束,观察最终栈、执行停止原因和关键断点位置:

bash
./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 输入一一对应

正向用例可以使用上一节的批量脚本。对于预期失败的用例,可单独写一个反向检查脚本:

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 ]

预期失败用例不要只检查“失败了”,还要在人工审查时确认错误类型是你想覆盖的类型,例如所有权错误、类型不匹配、非法 Range() 参数或非法子作用域副栈操作。


方法四:调试信息辅助测试

使用 -d 标志编译,生成调试信息文件,然后配合调试器检查每一行的执行情况:

bash
./utxo_compiler my_contract.ct -d

调试信息包含源码行号到字节码偏移的映射,可以用来:

  • 确认某段代码是否被执行
  • 检查循环展开和跳转位置是否正确
  • 验证条件分支的 PC 映射是否稳定
  • 检查函数符号、参数和局部变量信息是否完整

如果要指定调试信息文件名,请把 --debug-output-d 一起使用:

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

如果合约在私有函数或 if/else 子作用域中使用 SetAlt / SetMain,编译器默认会拦截这类写法,以避免副栈顺序被隐式破坏。确认你的副栈协议正确后,可显式开启:

bash
./utxo_compiler my_contract.ct --asa

--asa--allow-subscope-altstack 的短写。建议只在确实需要跨子作用域管理副栈时开启,并为相关分支补充调试器测试。


方法五:交易上下文测试

使用 BVM.* 字段的合约不能只靠普通参数测试。例如 Counter 这类状态合约通常需要证明:

  1. 当前输入花费的是正确的旧合约 UTXO。
  2. 父交易 txid 能由提供的 PreTX 数据重建出来。
  3. 当前交易输出的哈希与 BVM.outputsHash 一致。
  4. 新合约输出中的状态值按规则推进。

调试器提供 settxfile 用来加载交易上下文。文件采用每行一个键值对的文本格式:

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

在调试器中加载并查看:

text
(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.unlockingInputBVM.outputsHash 等上下文与构造交易匹配父交易 txid 重建失败、输出哈希不一致、状态推进错误

对于拒绝路径,测试通过的标志不是“运行结束”,而是“在预期位置失败”。如果错误签名在 CheckSigVerify 处终止、错误父交易在 txid 校验处终止,这就是有效的拒绝路径测试。


初级开发者易踩坑

陷阱一:把所有权错误当成运行时错误

所有权检查是编译期的——编译器会在 utxo_compiler 阶段报错。测试时只需确认编译失败位置和错误类型符合预期,不需要为“运行时所有权错误”准备调试器输入。

陷阱二:EqualVerify 失败不返回假,而是终止

EqualVerifyCheckSigVerify*Verify 函数在失败时直接终止执行(等价于脚本执行失败)。测试拒绝场景时,这类函数触发的失败不会在栈上留下 0,而是让整个执行中止。调试器中会显示执行被终止。

陷阱三:合约成员变量是字节码的一部分

测试同一个合约逻辑的不同实例数据时,不能只修改 unlocking script 参数。self.X、构造参数或锁定脚本后缀数据会影响 lock.hex,需要重新编译或替换锁定脚本中的实例数据。

陷阱四:循环次数是编译期常量

Range() 的参数必须是编译期可确定的值(字面量或常量表达式)。不能用运行时变量控制循环次数;需要覆盖不同循环规模时,应写成不同测试合约或不同常量配置。

陷阱五:导入库函数不是独立调用目标

verifyP2PKH 这类库函数需要通过调用它的合约公有函数来测试。它们不会作为独立 ABI 入口出现,也不需要单独提供解锁参数。

陷阱六:多公有函数的参数顺序容易反

多个公有函数会按声明顺序连续执行。解锁参数的压栈顺序必须与 ABI 和执行顺序配合;如果要表达互斥调用路径,优先使用单个公有入口加 path 参数分发。


快速回顾

  • 合约测试至少分为编译测试、调试执行测试和交易上下文测试。
  • “拒绝路径”测试成功的标志,是在预期位置失败,而不是一定要正常运行结束。
  • 所有权错误发生在编译期;EqualVerifyCheckSigVerify 失败会直接终止脚本。
  • 有状态合约需要重点测试父交易、当前输出、状态推进和输出顺序。
  • 测试数据要和 ABI、结构体字段顺序、固定数组长度保持一致。

下一步


🇬🇧 English version