在文章有状态的 UTXO 和 OP_PUSH_TX 的技术原理中,我们提到了计数器合约,它可以把自己被调用的次数记录在链上,具体为:
- 有一个 UTXO 被称作计数器合约,状态初始值为 0
- 该 UTXO 被花费时,必须生成一个新的计数器 UTXO,并将之前的状态值加 1 记录到新的 UTXO 中
- 规则 [2] 由矿工验证,如果没有生成符合要求的新 UTXO,就无法花费当前这个 UTXO
本文将介绍该合约 5 个不同版本的实现,带你了解如何使用 sCrypt 在 BSV 上开发智能合约。
v1
根据之前介绍的实现原理,我们把 UTXO 的锁定脚本分成两部分:逻辑(代码)和状态(数据)。
数据段(蓝色)占 1 字节,用于记录当前状态。代码段(黄色)是整个合约的控制逻辑(花费该 UTXO 的条件),用来保证合约的状态延续(强制传递锁定脚本逻辑)且状态的变化符合要求(状态随交易流转依次递增)。
实现状态的延续和控制需要在解锁 UTXO 时知道当前交易的上下文,可以通过在解锁脚本中放入当前交易的 sighash preimage(交易摘要)来实现,并使用 OP_PUSH_TX 技术来保证传入的 preimage 是真实的。
contract CounterV1 { |
代码的整体过程十分清晰:
- 调用 Tx.checkPreimage 函数来保证传入的 txPreimage 确实是当前交易的 sighash preimage
- 从 txPreimage 中解析出当前 UTXO 锁定脚本的原文,从而得到代码段(黄色)和数据段(蓝色)的内容
- 根据当前状态计算正确的新状态
- 验证当前交易的输出:构造出我们期望的当前交易的所有输出,要求它们的 SHA256 双哈希(
hash256(expectedOutput)
)跟 txPreimage 中的 hashOutputs(SigHash.hashOutputs(txPreimage)
)保持一致
请注意,sighash preimage 中与当前交易输出有关的项只有 hashOutputs,它的值具体是什么与签名时使用的 SIGHASH 标记有关。
函数 Tx.checkPreimage 会使用 SIGHASH_ALL 来进行链上的签名和验签,所以此时从 txPreimage 中解析出的 hashOutputs,是按顺序拼接当前交易所有输出(8 字节小端的金额、锁定脚本的长度、脚本的内容)后的 SHA256 双哈希。这就是最后一行代码的含义:我们期望每次调用这个合约的交易只有一个输出。
另外注意到,用 1 字节记录整数会有“溢出”的问题。代码 num2bin(counter, dataLen)
会在 counter 等于 128 时报错(整数 128 无法编码成 1 字节)。
v2
手动解析和构造解锁脚本看起来有些繁琐,我们可以使用 state 关键字来简化。
contract CounterV2 { |
与 v1 相比,v2 代码没有任何逻辑上的变化,但不再需要手动处理脚本,简洁了很多:
- 验证 txPreimage
- 处理数据
- 验证当前交易的输出
注意到,这里使用的是 sCrypt int 类型来记录状态,它没有溢出风险。
我们可以不断调用 v2 合约,让计数器的值一直增加下去。如果部署合约时锁定了 5000 聪 BSV,每次调用都需要 2000 聪矿工费,那么在第三次调用时会余额(1000 聪)不足,这时候需要增加交易输入“为合约充值”。因为代码里没有对合约之外的其它交易输入做任何限制,所以你才能在调用合约的交易中添加其它输入。但注意,这些交易只能有 1 个输出。
在实际使用过程中,只允许交易有 1 个输出很不方便,因为不能在“充值调用”时添加找零,它会把所有其它输入中的金额都充值到合约中,或作为矿工费消耗掉。
v3
给 v2 合约新增一种解锁方法就可以解决这个问题。
contract CounterV3 { |
我们把 v2 的 unlock 方法重命名成 increase 保留,用来兼容之前所有的交互方式,同时新增 fund 解锁方法,用来支持“充值调用”时的找零。
请注意,fund 解锁方法要求调用交易必须有 2 个输出:第一个输出是合约本身,第二个输出是交易找零。为了能在解锁时确定所有的交易输出,我们需要把 fundAmount、changePkh 和 changeAmount 都放到解锁脚本里。另外还要保证 fundAmount 的值大于 0,以避免在调用时从合约中“偷取”资金。
这个版本的代码看起来有些复杂,它有两种不同的解锁方式,你可以这么认为:sCrypt 会帮你把这些解锁方法都合并到一起,“智能”的构造出统一的包括所有逻辑的锁定脚本,不需要你费心。
v4
前三个版本的代码,都会在合约中锁定一定数量的 BSV。这可以用 SIGHASH_SINGLE 标记来优化,让合约只锁定最少数量的 BSV(满足 dust 限制即可)。
contract CounterV4 { |
新的 v4 代码与之前的合约相比,有两处不同:
- 调用 Tx.checkPreimageSigHashType 函数并传入指定的 sigHashType 来验证 preimage 的真实性
- 新的合约输出与之前锁定同样数量的 BSV
这样的处理十分精妙,不仅能兼容已实现的所有功能,还能减少合约中锁定的 BSV,每次调用时你只需添加其它输入支付矿工费即可。SIGHASH_SINGLE 会让 txPreimage 中的 hashOutputs 只包含与当前输入(合约输入)相对应的输出(合约输出)的 SHA256 双哈希。所以最后一行代码既能保证合约的状态延续,又能让你随意添加其它交易输出(在“充值调用”时增加一个或多个找零)。
当然,你还可以使用 SIGHASH_SINGLE | ANYONECANPAY 标记让调用合约的交易变得更灵活,它不仅允许你在合约输出的后面添加任意输出,还允许你在合约输入的后面添加任意输入。我们在上文中提到:可以在调用(不限制其它输入的)合约时添加输入“为合约充值”。这两点并不矛盾,对之前调用合约的交易,你确实可以添加输入来“为合约充值”,但在调用交易的所有输入都签名后,就不能再添加其它输入了,否则会改变 preimage 导致现有输入中的所有签名都失效,而 ANYONECANPAY 会放开这个限制,让你可以在签名后的调用交易上继续添加其它输入。
v5
对“有状态”的 UTXO,因为锁定脚本的逻辑会被强制传递,所以其状态才能在交易链条中保持住。也就是说,不论何时都会有一个 UTXO 记录着合约的当前状态。由于粉尘限制(dust limit)的存在,我们又必须在合约的 UTXO 中锁定少量的 BSV。这两点一结合,就搞出了新“问题”:合约里锁定的 BSV 会被永久锁定,它们就像被“燃烧”了一样,再也无法使用。
别慌,问题不大。我们可以在 v4 代码的基础上,增加一个 destroy 方法来“销毁”合约,以回收被锁定的 BSV。
contract CounterV5 { |
在本例中,我们限制了只有部署合约的人(creatorPkh)才可以执行销毁,就像 P2PKH 一样,要求提供的签名和公钥能与 creatorPkh 相匹配。v5 合约在 BSV 测试网上的运行效果如下:
- 部署 v5 合约,状态 0
- 调用 increase,状态 0 –> 1
- 调用 increase,状态 1 –> 2
- 调用 increase,状态 2 –> 3
- 调用 increase,状态 3 –> 4
- 调用 increase,状态 4 –> 5
- 调用 destroy,销毁合约
代码
总结
- 要想实现“有状态”的合约就必须使用 preimage(作为参数传入解锁方法),因为要获取当前 UTXO 的锁定脚本,并限制新产生 UTXO 的锁定脚本
- sighash preimage 中与交易输出有关的项只有 hashOutputs,它是所有交易输出(SIGHASH_ALL)或与当前输入相对应的交易输出(SIGHASH_SINGLE)的 SHA256 双哈希,也可能是 32 字节的 0x00(SIGHASH_NONE)
- 因为 hashOutputs 是一个哈希值,所以我们必须要能在解锁方法中完全确定当前交易(需要被限制)的所有输出,才可以计算出期望的 hashOutputs,从而通过限制它跟 txPreimage 中 hashOutputs 的值保持一致来控制交易输出