Skip to content

如何部署与调用合约


本文说明从编译合约到在比特币网络上部署、调用合约的完整流程。UTXO_Compiler 负责把 .ct 合约编译成锁定脚本字节码;上链部署和调用需要配合钱包或交易构造工具完成。


编译输出

运行编译器后,会得到一份 JSON 格式的字节码描述:

bash
./utxo_compiler my_contract.ct

简化后的输出结构如下:

json
{
  "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 需要提供哪些数据。
  • structsPreTXCurrentTX 这类结构体参数的字段布局。
  • 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

部署流程

  1. 编译合约并读取 lock.hex
  2. 替换 <self.pubKeyHash> 这类实例占位符。
  3. 如果是有状态合约,先把初始状态字节写入实例数据区域。
  4. 构造一个交易输出,使用最终合约脚本作为 locking script。
  5. 用你的钱包或交易库签名并广播交易。

广播成功后,部署交易中的合约输出,例如 txid:0,就是这个合约实例。后续“调用合约”,本质上就是构造一笔交易花费这个输出。

如果是在本地连续测试未确认交易,交易库通常可以把刚创建的输出直接转换成下一笔交易的输入。

对于 <self.pubKeyHash> 这类构造参数占位符,建议把替换逻辑集中在一个小 helper 中:读取 constructorParams,按类型把整数编码成小端序,然后在脚本上替换所有 <self.name> 占位符。

部署示例

假设 tbcreadFilebroadcastRawTx 由你的交易工具提供:

ts
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 执行时,会先把 sigpubKey 压入主栈,再执行 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 项:

ts
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());

状态合约调用

普通签名合约只需要签名、公钥等参数。有状态合约还需要证明:

  1. 当前输入花费的是正确的旧合约 UTXO。
  2. 当前交易创建了符合规则的新合约输出。

这类合约通常会用结构体描述父交易和当前交易输出,例如 PreTXCurrentTX。调用时,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 补齐。代码里的 08102028 等常量分别是 8 字节金额、16 字节交易头、32 字节哈希、40 字节输入记录的 push 长度。

对状态合约,通常要先构造下一份输出脚本,再按 ABI 结构体序列化父交易数据和当前交易输出,最后按栈顺序拼接这些 hex 数据。以 Counter 模式为例,verifyCurrentTX(ctx)getCountFromPreTX(pretx) 之后执行,所以 unlocking script 先放 ctx 数据,后放 pretx 数据。

如果合约还声明了当前输入数据或两级祖先交易数据,也按同样方式处理:先从 ABI 推导结构体长度,再严格按结构体字段顺序序列化,最后按栈顺序拼接各个参数组。


初级开发者易踩坑

  • 实例数据必须精确匹配self.XSuffixData 多一个字节、少一个字节都可能导致合约校验失败。
  • 参数顺序要和 ABI 一致:unlocking script 的 push 顺序会影响栈上参数位置;多个公有函数时,函数参数组通常按执行顺序反向拼接。
  • 多公有函数会串行执行:调用方必须提供所有需要的数据,不能在调用时任选一个 public 函数。
  • 库函数只在内部调用verifyP2PKH 这类导入函数不是 ABI 入口,也不会接收独立的解锁参数。
  • 交易上下文由网络提供BVM.outputsHashBVM.unlockingInput 等元数据来自当前花费交易;本地调试时需要提供模拟交易数据。
  • 固定数组长度属于 ABI 约束:如果 PreTX.OutputsOutput[3],就最多序列化 3 个输出,缺失位置也要按合约约定填充。
  • partialOffset 必须来自真实脚本:除非脚本布局固定且经过测试,否则不要手写常量;应从实际脚本字节中推导。
  • 先更新状态,再计算输出承诺:有状态合约要先构造下一份输出脚本,再序列化 CurrentTX;否则 BVM.outputsHash 会承诺到错误的字节。
  • 手续费由调用方决定:合约一般不直接约束手续费,但交易必须满足节点和矿工的费用要求。
  • 签名 preimage 要一致CheckSig 校验依赖具体 SIGHASH 规则,签名时要使用与节点验证一致的 preimage。
  • 广播前先本地验证:建议先用交易库的本地验证方法检查交易格式、签名和脚本,再广播到网络。

快速回顾

  • 部署合约就是创建一个使用 lock.hex 作为锁定脚本的 UTXO。
  • 调用合约就是构造一笔新交易,花费这个合约 UTXO,并在 unlocking script 中提供参数。
  • 单个公有函数按 ABI 参数顺序提供数据;多个公有函数要注意参数组的压栈顺序。
  • 有状态合约还要提供父交易和当前交易输出数据,让合约验证状态是否正确推进。
  • 构造真实交易前,先用本地验证和调试器确认脚本、签名、输出承诺都匹配。

下一步


English version