如何编写合约
本节系统介绍 UTXO_Compiler 合约语言的完整语法。如果你更喜欢从例子入手,可以先看 教程一,再回来查阅本节作为参考。
1. 合约结构
每个 .ct 文件只能定义一个合约,基本骨架如下:
import std.p2pkh # 可选:在合约前导入库
Contract 合约名:
# 结构体定义(可选,可有多个)
Struct 结构体名:
字段名: 类型
# 公有函数(至少一个,作为合约的花费入口)
def 函数名(参数: 类型):
...
# 私有辅助函数(可选,以下划线开头)
def _辅助函数名(参数: 类型):
...合约名、函数名、字段名均为标识符,规则与 Python 一致:字母或下划线开头,后跟字母、数字或下划线。
Library 与 import
.ct 文件仍然只定义一个 Contract,但可以在合约前复用辅助代码。常见导入方式有两类:
import std.p2pkh # 从标准库根目录解析 std/p2pkh.ct
import "./lib/math.ct" # 从当前文件目录解析相对路径库使用 Library <name>: 声明,可以定义 Struct 和辅助函数,但不能定义 main 这类只属于合约的入口:
Library std.p2pkh:
def verifyP2PKH(signature: hex, pubKey: hex, pubKeyHash: hex):
EqualVerify(Hash160(pubKey.Clone()), pubKeyHash)
result = CheckSig(signature, pubKey)
return result导入后,库成员会在编译期对宿主合约可用。库函数按私有辅助函数处理:合约内部可以调用它们,但即使函数名不以下划线开头,也不会作为 ABI 中的公有花费入口暴露。
实例数据建议由宿主合约在调用库函数时显式传入。例如 std.p2pkh 把 pubKeyHash 作为参数接收,宿主合约调用时传入 self.pubKeyHash。
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 个字符)。hex 和 string 底层都是字节序列,区别仅在字面量书写形式。
定长 hex 类型
在结构体字段中,可以使用 hexN 声明固定字节长度的十六进制字段,常用于描述比特币交易的固定格式字段:
Struct TxInput:
txid: hex32 # 32 字节交易 ID
vout: hex4 # 4 字节输出索引
sequence: hex4 # 4 字节序列号定长 hexN 类型常与内联匿名结构类型配合使用,用来临时描述一段确定字节布局的数据。
数组类型
结构体字段、函数参数和 self 成员都支持固定长度数组 Type[N]。常见写法分别如下。
结构体字段可以直接把字段类型写成 Type[N]:
Struct Transaction:
Inputs: TxInput[3] # 3 个输入
Outputs: TxOutput[3] # 3 个输出函数参数同样可以声明为固定长度数组:
def main(sigs: hex[3], pubKeys: hex[3], masks: bool[3]):
...self 成员作为部署时替换的实例数据,也可以按固定长度数组下标读取:
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 循环中产生的变量。若下标为整形变量,则一般与结构体数组字段组合使用,取到对应的结构体字段。
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 拆分为独立元素。
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 字节处理,适合金额、计数器、索引等整数序列场景。
如果你需要在结构体中表达一组计数值,也可以直接声明:
Struct BatchData:
counts: uint64[4]内联匿名结构类型
临时使用的复合字段,可以不单独定义结构体,直接内联:
utxoData: {txid: hex32, vout: hex4, sequence: hex4}
utxoData = Push(BVM.unlockingInput)
vout = BinToNum(utxoData.vout)3. 变量
赋值
count = 10
result = Hash160(pubKey) # 将函数返回值绑定到变量局部变量(除数组和内联匿名结构类型外)可直接赋值使用,结构体字段和函数参数必须声明类型。
合约成员变量(self)
合约成员变量可直接使用,在编译期会被替换为字节码中的固定常量,可在公有函数和私有函数中多次读取,不受所有权限制:
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. 运算符
算术运算
sum = a + b
diff = a - b
prod = a * b
quot = a / b # 整除取模没有对应运算符,使用内置函数 Mod(a, b)。
比较运算
a == b # 等于
a != b # 不等于
a < b # 小于
a > b # 大于
a <= b # 小于等于
a >= b # 大于等于比较运算返回整数 1(真)或 0(假),可直接用于 if 条件或 Return。
逻辑运算
合约语言没有 and / or / not 关键字,逻辑运算统一用内置函数:
ok = And(condition1, condition2) # 逻辑与
ok = Or(condition1, condition2) # 逻辑或
ok = Not(condition) # 逻辑非5. 控制流
条件语句
if amount > threshold:
CheckSigVerify(sig, pubKey)
else:
Return (1 == 0) # 拒绝if,else 分支需成对出现,多分支需嵌套 if:
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):
# 从 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 中,并按声明顺序串行执行:
Contract MultiPath:
# 第一步:先验证父交易
def loadPreviousState(pretx: PreTX):
...
# 第二步:再验证当前交易
def verifyCurrent(ctx: CurrentTX):
...这与很多智能合约 SDK 中“调用某一个 public method”的模型不同。所有公有函数会形成同一条锁定脚本中的连续花费流程;如果你想表达多条互斥路径,常见写法是在单个公有入口里加入 path 参数,再用 if/else 分发。
def main(sig: hex, pubKey: hex, path: int):
if path == 0:
_spend(sig, pubKey)
else:
_refund(sig, pubKey)这样可以让解锁接口更清晰:调用方提供一个路径选择参数,再提供该路径需要的数据。
私有辅助函数
以 _ 开头的函数只能在合约内部调用,用于封装重复逻辑:
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":
Return (1 == 1) # 永远通过
Return (1 == 0) # 永远拒绝
Return CheckSig(sig, pubKey) # 签名验证结果作为返回值
Return And(cond1, cond2) # 组合条件小写 return 用于私有函数将值返回给调用方:
def _computeHash(data: hex):
result = Hash160(data)
return result7. 结构体
结构体描述复合数据的字节布局,通常对应交易数据中某一段的格式:
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]结构体字段访问通过 . 操作符,支持多级链式访问:
scriptSize = pretx.Outputs[0].LockingScript.Size注意:字段访问会消耗该字段的所有权。访问同一字段两次,需要在第一次前先
.Clone()。详见 所有权系统。
8. 解构赋值
{} 语法用于接收返回多个值的函数的结果,或初始化结构体:
# 接收 Split 的两个返回值
{header, body} = Split(rawData, 4)
# 接收私有函数的多返回值
{x, y} = _getCoords(encoded)
# 结构体字面量初始化(按字段顺序)
point: Point = {10, 20}9. 常见合约写作模式
多数合约大致可以分成四类:签名锁、分支锁、状态延续合约、交易结构校验合约。写新合约时,建议先判断自己属于哪一类,再选对应骨架。
最小签名锁:库优先
简单 P2PKH 钱包不要重复手写脚本模板,优先导入标准辅助库:
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 清掉。
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,同时要求新交易生成符合规则的新状态。常见结构如下:
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取出当前输入引用的txid和vout; - 用
pretx重建父交易哈希,并与当前输入里的txid比对; - 从
pretx.Outputs[vout]读取旧状态、旧金额、旧脚本PartialHash/Size。
当前交易校验通常做两件事:
- 检查某个输出延续了相同的合约代码段,例如
PartialHash和Size不变; - 拼接所有被承诺的输出,计算
Sha256(outputs_data),再与BVM.outputsHash比对。
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)预言机和外部消息:先验签,再解析
预言机或外部消息合约的常见写法是:先保留一份消息副本用于解析,再把原消息交给签名验证函数。
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,避免最高位被解释成负数:
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. 注释
使用 # 进行行注释:
# 这是一整行注释
count: int = 0 # 这是行尾注释目前不支持多行注释,较长的注释需每行都加 #。
初级开发者易踩坑
- 一个文件写多个合约:每个
.ct文件只能有一个Contract。 - 把多个互斥入口写成多个 public 函数:多个 public 函数会串行执行;互斥路径通常用
main(path, ...)。 - 变量被用过还继续用:局部变量传给内置函数后常会被消耗,需要复用时先
.Clone()。 - 循环次数依赖运行时参数:
Range()的边界必须在编译期确定。 - 结构体字段顺序随意写:序列化交易数据时,字段顺序必须和
Struct声明一致。
快速回顾
- 合约文件由
Contract、类型声明、函数和可选Struct组成。 - 公有函数是花费入口;私有函数适合拆分内部逻辑。
self.X是合约实例数据,可以多次读取,不受普通局部变量所有权限制。Clone用于复制需要复用的值,Delete用于清理不用的值。- 状态合约通常同时验证父交易和当前交易输出。
下一步
- 如何部署与调用合约 — 编译输出与链上调用流程