Skip to content

如何编写合约


本节系统介绍 UTXO_Compiler 合约语言的完整语法。如果你更喜欢从例子入手,可以先看 教程一,再回来查阅本节作为参考。


1. 合约结构

每个 .ct 文件只能定义一个合约,基本骨架如下:

python
import std.p2pkh      # 可选:在合约前导入库

Contract 合约名:
    # 结构体定义(可选,可有多个)
    Struct 结构体名:
        字段名: 类型

    # 公有函数(至少一个,作为合约的花费入口)
    def 函数名(参数: 类型):
        ...

    # 私有辅助函数(可选,以下划线开头)
    def _辅助函数名(参数: 类型):
        ...

合约名、函数名、字段名均为标识符,规则与 Python 一致:字母或下划线开头,后跟字母、数字或下划线。

Library 与 import

.ct 文件仍然只定义一个 Contract,但可以在合约前复用辅助代码。常见导入方式有两类:

python
import std.p2pkh          # 从标准库根目录解析 std/p2pkh.ct
import "./lib/math.ct"    # 从当前文件目录解析相对路径

库使用 Library <name>: 声明,可以定义 Struct 和辅助函数,但不能定义 main 这类只属于合约的入口:

python
Library std.p2pkh:
    def verifyP2PKH(signature: hex, pubKey: hex, pubKeyHash: hex):
        EqualVerify(Hash160(pubKey.Clone()), pubKeyHash)
        result = CheckSig(signature, pubKey)
        return result

导入后,库成员会在编译期对宿主合约可用。库函数按私有辅助函数处理:合约内部可以调用它们,但即使函数名不以下划线开头,也不会作为 ABI 中的公有花费入口暴露。

实例数据建议由宿主合约在调用库函数时显式传入。例如 std.p2pkhpubKeyHash 作为参数接收,宿主合约调用时传入 self.pubKeyHash

python
import std.p2pkh

Contract Wallet:
    def main(signature: hex, pubKey: hex):
        ok = verifyP2PKH(signature, pubKey, self.pubKeyHash)

import 会做循环导入检测,重复导入同一个库时只展开一次。需要调整标准库搜索路径时,请使用你的编译器配置。


2. 数据类型

基础类型

类型描述字面量示例
int,number整数(BVM 大整数)0, 42, -100
string字节字符串"hello", "world"
hex十六进制字节数组0x1234, 0xdeadbeef
bool布尔值1(真), 0(假)
address比特币 P2PKH 地址"1RainRzqJtJxHTngafpCejDLfYq2y4KBc"

address 类型只支持标准 Base58 P2PKH 地址(以 1 开头,34 个字符)。hexstring 底层都是字节序列,区别仅在字面量书写形式。

定长 hex 类型

在结构体字段中,可以使用 hexN 声明固定字节长度的十六进制字段,常用于描述比特币交易的固定格式字段:

python
Struct TxInput:
    txid:     hex32   # 32 字节交易 ID
    vout:     hex4    # 4 字节输出索引
    sequence: hex4    # 4 字节序列号

定长 hexN 类型常与内联匿名结构类型配合使用,用来临时描述一段确定字节布局的数据。

数组类型

结构体字段、函数参数和 self 成员都支持固定长度数组 Type[N]。常见写法分别如下。

结构体字段可以直接把字段类型写成 Type[N]

python
Struct Transaction:
    Inputs:  TxInput[3]    # 3 个输入
    Outputs: TxOutput[3]   # 3 个输出

函数参数同样可以声明为固定长度数组:

python
def main(sigs: hex[3], pubKeys: hex[3], masks: bool[3]):
    ...

self 成员作为部署时替换的实例数据,也可以按固定长度数组下标读取:

python
    for i in Range(2, -1, -1):
        if masks[i] == 1:
            SetMain(total)
            if And(Equal(Hash160(pubKeys[i].Clone()), self.addr[i]), CheckSig(sigs[i], pubKeys[i])):
                total = total + 1
                SetAlt(total)
            else:
                total = total + 0
                SetAlt(total)
        else:
            Delete(pubKeys[i])
            Delete(sigs[i])

数组下标访问:tx.Inputs[0]pubKeys[i]self.pubKeyHashes[0]。下标可以是整型字面量,也可以是在固定 Range 循环中产生的变量。若下标为整形变量,则一般与结构体数组字段组合使用,取到对应的结构体字段。

python
    vout = BinToNum(BVM.unlockingInput.Slice(32, 4)) #获取code在父交易中输出的位置
    vout_copy = vout.Clone()
    #取到code_data
    code_data = pretx.Outputs[vout_copy].LockingScript.SuffixData.Clone()

uint64[] 数组

除了结构体数组外,合约里也常用 uint64[](写作 uint64[N])来表达定长 64 位无符号整数数组,未使用时数组字段作为整体占据一个栈高,需要使用时通过 OP_ROLL 移动到栈顶,然后通过多次 OP_SPLIT 拆分为独立元素。

python
    amount: uint64[6] = temp_data.Slice(3, 48)
    ft_amount_tax = Push(0)
    for i in Range(5, -1, -1):
        ft_amount_tax = BinToNum(amount[i]) + ft_amount_tax

使用要点:

  • uint64[N] 中的 N 必须是编译期可确定的固定长度;
  • 元素访问使用 arr[i],索引建议配合 Range 按固定边界遍历;
  • uint64 元素按 8 字节处理,适合金额、计数器、索引等整数序列场景。

如果你需要在结构体中表达一组计数值,也可以直接声明:

python
Struct BatchData:
    counts: uint64[4]

内联匿名结构类型

临时使用的复合字段,可以不单独定义结构体,直接内联:

python
utxoData: {txid: hex32, vout: hex4, sequence: hex4}
utxoData = Push(BVM.unlockingInput)
vout = BinToNum(utxoData.vout)

3. 变量

赋值

python
count = 10
result = Hash160(pubKey)     # 将函数返回值绑定到变量

局部变量(除数组和内联匿名结构类型外)可直接赋值使用,结构体字段和函数参数必须声明类型。

合约成员变量(self)

合约成员变量可直接使用,在编译期会被替换为字节码中的固定常量,可在公有函数和私有函数中多次读取,不受所有权限制

python
Contract P2PKH:
    def verify(sig: hex, pubKey: hex):
        pubKey_copy = pubKey.Clone()
        pubKeyHash = Hash160(pubKey_copy)
        EqualVerify(pubKeyHash, self.pubKeyHash)
        result = CheckSig(sig, pubKey)

4. 运算符

算术运算

python
sum   = a + b
diff  = a - b
prod  = a * b
quot  = a / b   # 整除

取模没有对应运算符,使用内置函数 Mod(a, b)

比较运算

python
a == b    # 等于
a != b    # 不等于
a <  b    # 小于
a >  b    # 大于
a <= b    # 小于等于
a >= b    # 大于等于

比较运算返回整数 1(真)或 0(假),可直接用于 if 条件或 Return

逻辑运算

合约语言没有 and / or / not 关键字,逻辑运算统一用内置函数:

python
ok = And(condition1, condition2)   # 逻辑与
ok = Or(condition1, condition2)    # 逻辑或
ok = Not(condition)                # 逻辑非

5. 控制流

条件语句

python
if amount > threshold:
    CheckSigVerify(sig, pubKey)
else:
    Return (1 == 0)   # 拒绝

if,else 分支需成对出现,多分支需嵌套 if

python
if role == 1:
    _handleBuyer(sig, pubKey)
else:
    if role == 2:
        _handleSeller(sig, pubKey)
    else:
        Return (1 == 0)

循环语句

循环使用 for ... in Range(start, stop, step) 形式,语义类似 Python 的 range,但参数顺序是 (start, stop_exclusive, step)

python
# 从 2 递减到 0(包含 0)
for i in Range(2, -1, -1):
    data = Cat(items[i], data)

# 从 0 递增到 2(包含 2)
for i in Range(0, 3, 1):
    total = Add(total, values[i])

循环次数在编译期必须确定,主要用于遍历固定大小的数组或执行固定次数的操作。


6. 函数

公有函数(花费入口)

不以 _ 开头的函数是公有函数,会出现在 ABI 中,并按声明顺序串行执行:

python
Contract MultiPath:
    # 第一步:先验证父交易
    def loadPreviousState(pretx: PreTX):
        ...

    # 第二步:再验证当前交易
    def verifyCurrent(ctx: CurrentTX):
        ...

这与很多智能合约 SDK 中“调用某一个 public method”的模型不同。所有公有函数会形成同一条锁定脚本中的连续花费流程;如果你想表达多条互斥路径,常见写法是在单个公有入口里加入 path 参数,再用 if/else 分发。

python
def main(sig: hex, pubKey: hex, path: int):
    if path == 0:
        _spend(sig, pubKey)
    else:
        _refund(sig, pubKey)

这样可以让解锁接口更清晰:调用方提供一个路径选择参数,再提供该路径需要的数据。

私有辅助函数

_ 开头的函数只能在合约内部调用,用于封装重复逻辑:

python
def _verifyOwner(sig: hex, pubKey: hex, expectedHash: hex):
    pubKeyCopy = pubKey.Clone()
    pubKeyHash = Hash160(pubKeyCopy)
    EqualVerify(pubKeyHash, expectedHash)
    CheckSigVerify(sig, pubKey)

def spend(sig: hex, pubKey: hex, ownerHash: hex):
    ...
    _verifyOwner(sig, pubKey, ownerHash)
    ...

返回语句

Return(大写)将表达式结果压入执行栈并生成"OP_RETURN":

python
Return (1 == 1)               # 永远通过
Return (1 == 0)               # 永远拒绝
Return CheckSig(sig, pubKey)  # 签名验证结果作为返回值
Return And(cond1, cond2)      # 组合条件

小写 return 用于私有函数将值返回给调用方:

python
def _computeHash(data: hex):
    result = Hash160(data)
    return result

7. 结构体

结构体描述复合数据的字节布局,通常对应交易数据中某一段的格式:

python
Struct Script:
    SuffixData:  string
    PartialHash: string
    Size:        int

Struct Output:
    Value:         int
    LockingScript: Script    # 结构体可以嵌套

Struct PreTX:
    VLIO:                string
    Inputs:              Input[3]
    UnlockingScriptHash: string
    Outputs:             Output[3]

结构体字段访问通过 . 操作符,支持多级链式访问:

python
scriptSize = pretx.Outputs[0].LockingScript.Size

注意:字段访问会消耗该字段的所有权。访问同一字段两次,需要在第一次前先 .Clone()。详见 所有权系统


8. 解构赋值

{} 语法用于接收返回多个值的函数的结果,或初始化结构体:

python
# 接收 Split 的两个返回值
{header, body} = Split(rawData, 4)

# 接收私有函数的多返回值
{x, y} = _getCoords(encoded)

# 结构体字面量初始化(按字段顺序)
point: Point = {10, 20}

9. 常见合约写作模式

多数合约大致可以分成四类:签名锁、分支锁、状态延续合约、交易结构校验合约。写新合约时,建议先判断自己属于哪一类,再选对应骨架。

最小签名锁:库优先

简单 P2PKH 钱包不要重复手写脚本模板,优先导入标准辅助库:

python
import std.p2pkh

Contract MyWallet:
    def main(signature: hex, pubKey: hex):
        ok = verifyP2PKH(signature, pubKey, self.pubKeyHash)

宿主合约把 self.pubKeyHash 传给库函数,部署时把该占位符替换成真实 20 字节 Hash160。合约文件只保留业务入口,标准校验逻辑留在库里复用。

多路径合约:一个入口加 path

公有函数会串行执行,所以互斥路径不要写成多个 public 方法。推荐让 main 接收 path,执行选中的分支,未使用的参数用 Delete 清掉。

python
Contract AtomicSwap:
    def main(x: hex, sig: hex, path: number):
        if path == 1:
            EqualVerify(Sha256(x), self.hashX)
            CheckSigVerify(sig, self.receiver)
        else:
            Delete(x)
            NumEqualVerify(GreaterOrEqual(BVM.locktime, self.timeout), 1)
            CheckSigVerify(sig, self.sender)

这种结构也适合超时退款、拍卖关闭、交换取消等场景。分支越复杂,越应该把每条路径拆成私有函数,让 main 只做分发。

状态延续:同时验证父交易和当前输出

状态延续合约会花费旧状态 UTXO,同时要求新交易生成符合规则的新状态。常见结构如下:

python
Struct Script:
    SuffixData: string
    PartialHash: string
    Size: number

Struct Output:
    Value: number
    LockingScript: Script

Struct Input:
    Data: {txid: hex32, vout: hex4, sequence: hex4}

Struct PreTX:
    VLIO: string
    Inputs: Input[3]
    UnlockingScriptHash: string
    Outputs: Output[3]

Struct CurrentTX:
    Outputs: Output[3]

父交易校验通常做三件事:

  • BVM.unlockingInput 取出当前输入引用的 txidvout
  • pretx 重建父交易哈希,并与当前输入里的 txid 比对;
  • pretx.Outputs[vout] 读取旧状态、旧金额、旧脚本 PartialHash/Size

当前交易校验通常做两件事:

  • 检查某个输出延续了相同的合约代码段,例如 PartialHashSize 不变;
  • 拼接所有被承诺的输出,计算 Sha256(outputs_data),再与 BVM.outputsHash 比对。
python
outputs_data = Push(0)
SetAlt(outputs_data)
for i in Range(2, -1, -1):
    size = ctx.Outputs[i].LockingScript.Size.Clone()
    if size != 0:
        temp = PartialHash(ctx.Outputs[i].LockingScript.SuffixData, ctx.Outputs[i].LockingScript.PartialHash, ctx.Outputs[i].LockingScript.Size)
        temp = Cat(ctx.Outputs[i].Value, temp)
        SetMain(outputs_data)
        outputs_data = Cat(temp, outputs_data)
        SetAlt(outputs_data)
    else:
        Delete(ctx.Outputs[i].LockingScript.Size)
        Delete(ctx.Outputs[i].LockingScript.PartialHash)
        Delete(ctx.Outputs[i].LockingScript.SuffixData)
        Delete(ctx.Outputs[i].Value)

SetMain(outputs_data)
EqualVerify(Sha256(outputs_data), BVM.outputsHash)

预言机和外部消息:先验签,再解析

预言机或外部消息合约的常见写法是:先保留一份消息副本用于解析,再把原消息交给签名验证函数。

python
msg_for_parse = msg.Clone()
schnorrVerify(msg, R, s, self.oraclePubKey, self.generator, self.modulus)

{asset_id, rest1} = Split(msg_for_parse, 32)
{timestamp_b, price_b} = Split(rest1, 8)
EqualVerify(asset_id, self.assetId)

如果字段是无符号小端整数,常见做法是在转数字前拼上 0x00,避免最高位被解释成负数:

python
price_num = BinToNum(Cat(price_b, 0x00))

写作检查清单

  • 先决定入口模型:单一路径用 main,互斥路径用 main(path, ...),连续验证步骤才拆成多个公有函数;
  • 所有交易片段都用 Struct 明确字节布局,不要用魔法偏移散落在各处;
  • 同一局部变量或结构体字段要读两次时,第一次使用前先 .Clone()
  • 某个分支不用的参数、字段或结构体要 Delete(...),保持栈形状清晰;
  • 循环边界必须固定,批量签名、批量输出、固定字段解析都用 Range
  • 需要跨循环或跨分支保存中间值时,用 SetAlt / SetMain 管理副栈;
  • 涉及当前交易输出时,最后用 BVM.outputsHash 做整体承诺校验;
  • 涉及父交易时,重建 preTXID 并与 BVM.unlockingInput.Slice(0, 32) 比对。

10. 注释

使用 # 进行行注释:

python
# 这是一整行注释
count: int = 0    # 这是行尾注释

目前不支持多行注释,较长的注释需每行都加 #


初级开发者易踩坑

  • 一个文件写多个合约:每个 .ct 文件只能有一个 Contract
  • 把多个互斥入口写成多个 public 函数:多个 public 函数会串行执行;互斥路径通常用 main(path, ...)
  • 变量被用过还继续用:局部变量传给内置函数后常会被消耗,需要复用时先 .Clone()
  • 循环次数依赖运行时参数Range() 的边界必须在编译期确定。
  • 结构体字段顺序随意写:序列化交易数据时,字段顺序必须和 Struct 声明一致。

快速回顾

  • 合约文件由 Contract、类型声明、函数和可选 Struct 组成。
  • 公有函数是花费入口;私有函数适合拆分内部逻辑。
  • self.X 是合约实例数据,可以多次读取,不受普通局部变量所有权限制。
  • Clone 用于复制需要复用的值,Delete 用于清理不用的值。
  • 状态合约通常同时验证父交易和当前交易输出。

下一步


🇬🇧 English version