如何部署与调用合约
本文说明从编译合约到在比特币网络上部署、调用合约的完整流程。UTXO_Compiler 负责把 .ct 合约编译成锁定脚本字节码;上链部署和调用需要配合钱包或交易构造工具完成。
编译输出
运行编译器后,会得到一份 JSON 格式的字节码描述:
./utxo_compiler my_contract.ct简化后的输出结构如下:
{
"metadata": {
"...": "..."
},
"abi": [
{
"type": "function",
"name": "verify",
"index": 0,
"params": [
{
"name": "sig",
"type": "hex"
},
{
"name": "pubKey",
"type": "hex"
}
]
}
],
"lock": {
"asm": "OP_DUP OP_HASH160 <self.pubKeyHash> OP_EQUALVERIFY OP_CHECKSIG",
"hex": "76a9<self.pubKeyHash>88ac"
},
"unlock": {
"verify": "<sig><pubKey>"
},
"functions": [
{
"name": "verify",
"type": "public",
"params": [
{
"name": "sig",
"type": "hex"
},
{
"name": "pubKey",
"type": "hex"
}
]
}
]
}关键字段:
lock.hex:十六进制锁定脚本字节码,可放入交易输出的 locking script。lock.asm:便于人工检查的操作码反汇编。abi/functions:合约公开函数和参数列表,用来确认调用时 unlocking script 需要提供哪些数据。structs:PreTX、CurrentTX这类结构体参数的字段布局。unlock:参数拼接模板,帮助你核对解锁数据的顺序。
部署合约
在 UTXO 模型里,“部署合约”就是创建一笔交易,让其中某个输出使用编译出的合约锁定脚本。
部署交易
├── 输入:已有 UTXO,提供资金
└── 输出:
├── value:锁入合约的聪数
└── locking script:utxo_compiler 编译出的合约字节码锁定脚本与实例数据
编译出的 lock.hex 通常包含两类数据:
- 合约逻辑:由
.ct代码编译出的脚本逻辑,同一个合约模板通常保持不变。 - 实例数据:部署某个具体实例时写入的数据,例如
self.pubKeyHash、截止时间、初始计数值等。
部署前,需要把 <self.xxx> 这类占位符替换为真实字节数据。以 P2PKH 为例,self.pubKeyHash 应替换成接收方公钥的 20 字节 Hash160。
对于有状态合约,脚本拆分位置也很重要。常见做法是把可变实例数据放在最后一个 OP_RETURN 以及后续 0xff padding 之后。以 Counter 为例,状态值编码为 08 + uint64LE(count),更新计数时只替换这 9 个字节,前面的合约逻辑保持不变。
UTXO_Compiler 不限定你使用哪一个钱包或交易库。只要你的工具能构造交易输出并设置 locking script,就可以使用编译出的
lock.hex。
部署流程
- 编译合约并读取
lock.hex。 - 替换
<self.pubKeyHash>这类实例占位符。 - 如果是有状态合约,先把初始状态字节写入实例数据区域。
- 构造一个交易输出,使用最终合约脚本作为 locking script。
- 用你的钱包或交易库签名并广播交易。
广播成功后,部署交易中的合约输出,例如 txid:0,就是这个合约实例。后续“调用合约”,本质上就是构造一笔交易花费这个输出。
如果是在本地连续测试未确认交易,交易库通常可以把刚创建的输出直接转换成下一笔交易的输入。
对于 <self.pubKeyHash> 这类构造参数占位符,建议把替换逻辑集中在一个小 helper 中:读取 constructorParams,按类型把整数编码成小端序,然后在脚本上替换所有 <self.name> 占位符。
部署示例
假设 tbc、readFile 和 broadcastRawTx 由你的交易工具提供:
const compiled = JSON.parse(await readFile("p2pkh.json", "utf8"));
const lockHex = compiled.lock.hex.replace("<self.pubKeyHash>", pubKeyHashHex);
const deployTx = new tbc.Transaction();
deployTx.from([fundingUtxo]);
deployTx.addOutput(new tbc.Transaction.Output({
script: tbc.Script.fromHex(lockHex),
satoshis: 10_000,
}));
deployTx.change(changeAddress);
deployTx.feePerKb(80);
deployTx.sign(privateKey);
await broadcastRawTx(deployTx.serialize());调用合约
“调用合约”就是构造一笔交易花费合约 UTXO。调用方需要在交易输入的 unlocking script 中提供合约需要的数据。
只有宿主 Contract 中声明的公有函数会形成 ABI 调用参数。导入的库函数可以被这些公有函数内部调用,但不会增加单独的解锁参数,也不会成为可选择的调用目标。
解锁脚本结构
unlocking script 由调用方构造,本质是一串 push 操作。先写入的数据在栈底,后写入的数据更靠近栈顶;locking script 开始执行时,会优先看到最后写入的数据。
# 例如 P2PKH 合约 verify(sig: hex, pubKey: hex)
Unlocking script = <sig> <pubKey>对于只有一个公有函数的合约,通常按 ABI 参数顺序写入即可。BVM 执行时,会先把 sig、pubKey 压入主栈,再执行 locking script 中的合约字节码。
多个公有函数的调用顺序
如果合约包含多个公有函数,它们会按声明顺序连续执行。调用时不需要选择某一个函数,而是要按合约要求提供所有公有函数需要的数据。
由于后写入的数据离栈顶更近,多个公有函数的参数组需要按执行顺序的反向拼接。每个函数组内部,则按编译产物里的 unlock[functionName] 模板顺序序列化。
例如 Counter 先执行 getCountFromPreTX(pretx),再执行 verifyCurrentTX(ctx);第一个函数需要先消费 pretx,所以 pretx 参数组必须最后写入:Unlocking script = <ctx fields> <pretx fields>。
如果你要表达“多个互斥调用路径”,推荐在单个公有入口中使用 path 参数,再在合约内部用 if/else 分发。
导入的库函数不参与这个公有函数执行序列。只有当某个公有函数调用它时,它才会执行。
执行时,BVM 会先把 unlocking script 数据压入主栈,再执行合约字节码。只要校验通过,栈顶结果为真,网络就会接受这笔花费交易。
调用示例
假设 buildUnlockingScript() 会把函数参数序列化为 push-data 项:
const unlockingScript = buildUnlockingScript([sigHex, pubKeyHex]);
const callTx = new tbc.Transaction();
callTx.from([contractUtxo, feeUtxo]);
callTx.addOutput(new tbc.Transaction.Output({
script: recipientScript,
satoshis: contractUtxo.satoshis - fee,
}));
callTx.change(changeAddress);
callTx.sign(privateKey); // 签名普通钱包输入,例如 feeUtxo
callTx.setInputScript({ inputIndex: 0 }, () => unlockingScript);
await broadcastRawTx(callTx.serialize());状态合约调用
普通签名合约只需要签名、公钥等参数。有状态合约还需要证明:
- 当前输入花费的是正确的旧合约 UTXO。
- 当前交易创建了符合规则的新合约输出。
这类合约通常会用结构体描述父交易和当前交易输出,例如 PreTX、CurrentTX。调用时,unlocking script 除了普通参数,还需要提供这些结构体的序列化数据。
典型的 Counter 类调用流程是:
- 从编译 JSON 中读取公开函数参数和结构体数组长度。
- 先构造下一份 locking script,包括将要创建的输出里的新状态字节。
- 序列化父交易中被花费的输出、输入列表、输出列表等数据。
- 序列化当前交易中需要被合约承诺的输出。
- 按合约公开函数的执行顺序拼接 unlocking script。
- 确保当前交易输出与合约内部的
BVM.outputsHash校验一致。
多公有函数场景要特别注意压栈顺序:unlocking script 后写入的数据会更靠近栈顶,因此先执行的函数参数通常需要放在解锁脚本的后半段。
对每个输出,序列化以下字段:
<value> <LockingScript.SuffixData> <LockingScript.PartialHash> <LockingScript.Size>长脚本使用 PartialHash + SuffixData + Size 表示。合约内可通过 PartialHash(...) 重新得到完整脚本承诺,避免每次都把大段合约逻辑放进 unlocking script。短脚本可以设置 PartialHash = 00,并把完整脚本放入 SuffixData。
对当前合约输出,必须使用真实的合约数据偏移拆分;对普通输出,可以按小于脚本长度的最大 64 字节边界拆分,长度小于 64 字节的脚本不拆分。
序列化器写入的数据形状要和编译出的结构体完全一致:固定宽度数字用小端序,字节串前面带 push-data 长度,固定数组里缺失的元素用空 push 补齐。代码里的 08、10、20、28 等常量分别是 8 字节金额、16 字节交易头、32 字节哈希、40 字节输入记录的 push 长度。
对状态合约,通常要先构造下一份输出脚本,再按 ABI 结构体序列化父交易数据和当前交易输出,最后按栈顺序拼接这些 hex 数据。以 Counter 模式为例,verifyCurrentTX(ctx) 在 getCountFromPreTX(pretx) 之后执行,所以 unlocking script 先放 ctx 数据,后放 pretx 数据。
如果合约还声明了当前输入数据或两级祖先交易数据,也按同样方式处理:先从 ABI 推导结构体长度,再严格按结构体字段顺序序列化,最后按栈顺序拼接各个参数组。
初级开发者易踩坑
- 实例数据必须精确匹配:
self.X或SuffixData多一个字节、少一个字节都可能导致合约校验失败。 - 参数顺序要和 ABI 一致:unlocking script 的 push 顺序会影响栈上参数位置;多个公有函数时,函数参数组通常按执行顺序反向拼接。
- 多公有函数会串行执行:调用方必须提供所有需要的数据,不能在调用时任选一个 public 函数。
- 库函数只在内部调用:
verifyP2PKH这类导入函数不是 ABI 入口,也不会接收独立的解锁参数。 - 交易上下文由网络提供:
BVM.outputsHash、BVM.unlockingInput等元数据来自当前花费交易;本地调试时需要提供模拟交易数据。 - 固定数组长度属于 ABI 约束:如果
PreTX.Outputs是Output[3],就最多序列化 3 个输出,缺失位置也要按合约约定填充。 partialOffset必须来自真实脚本:除非脚本布局固定且经过测试,否则不要手写常量;应从实际脚本字节中推导。- 先更新状态,再计算输出承诺:有状态合约要先构造下一份输出脚本,再序列化
CurrentTX;否则BVM.outputsHash会承诺到错误的字节。 - 手续费由调用方决定:合约一般不直接约束手续费,但交易必须满足节点和矿工的费用要求。
- 签名 preimage 要一致:
CheckSig校验依赖具体 SIGHASH 规则,签名时要使用与节点验证一致的 preimage。 - 广播前先本地验证:建议先用交易库的本地验证方法检查交易格式、签名和脚本,再广播到网络。
快速回顾
- 部署合约就是创建一个使用
lock.hex作为锁定脚本的 UTXO。 - 调用合约就是构造一笔新交易,花费这个合约 UTXO,并在 unlocking script 中提供参数。
- 单个公有函数按 ABI 参数顺序提供数据;多个公有函数要注意参数组的压栈顺序。
- 有状态合约还要提供父交易和当前交易输出数据,让合约验证状态是否正确推进。
- 构造真实交易前,先用本地验证和调试器确认脚本、签名、输出承诺都匹配。
下一步
- 如何测试合约 — 部署前的本地验证方案