计数器合约详解

在文章有状态的 UTXO 和 OP_PUSH_TX 的技术原理中,我们提到了计数器合约,它可以把自己被调用的次数记录在链上,具体为:

  1. 有一个 UTXO 被称作计数器合约,状态初始值为 0
  2. 该 UTXO 被花费时,必须生成一个新的计数器 UTXO,并将之前的状态值加 1 记录到新的 UTXO 中
  3. 规则 [2] 由矿工验证,如果没有生成符合要求的新 UTXO,就无法花费当前这个 UTXO

本文将介绍该合约 5 个不同版本的实现,带你了解如何使用 sCrypt 在 BSV 上开发智能合约。

v1

根据之前介绍的实现原理,我们把 UTXO 的锁定脚本分成两部分:逻辑(代码)和状态(数据)。

数据段(蓝色)占 1 字节,用于记录当前状态。代码段(黄色)是整个合约的控制逻辑(花费该 UTXO 的条件),用来保证合约的状态延续(强制传递锁定脚本逻辑)且状态的变化符合要求(状态随交易流转依次递增)。

实现状态的延续和控制需要在解锁 UTXO 时知道当前交易的上下文,可以通过在解锁脚本中放入当前交易的 sighash preimage(交易摘要)来实现,并使用 OP_PUSH_TX 技术来保证传入的 preimage 是真实的。

contract CounterV1 {

static const int dataLen = 1;

public function unlock(SigHashPreimage txPreimage, int outputAmount) {
// 1. verify preimage
//
require(Tx.checkPreimage(txPreimage));

// 2. get logic and data from locking script
// +------------+-----------+
// | logic part | data part |
// +------------+-----------+
//
// raw locking script from preimage
bytes scriptCode = SigHash.scriptCode(txPreimage);
int scriptLen = len(scriptCode);
// the last 1 byte is data
bytes logicPart = scriptCode[: scriptLen - dataLen];
bytes dataPart = scriptCode[scriptLen - dataLen :];
int counter = unpack(dataPart);

// 3. process data
//
counter++;

// 4. verify transaction output
//
// build the locking script for expected output
// +-----------------+---------------+
// | SAME logic part | NEW data part |
// +-----------------+---------------+
bytes expectedOutputScript = logicPart + num2bin(counter, dataLen);
// build expected output
bytes expectedOutput = Utils.buildOutput(expectedOutputScript, outputAmount);
// verify the current transaction output is the same as the expected output
require(SigHash.hashOutputs(txPreimage) == hash256(expectedOutput));
}
}

代码的整体过程十分清晰:

  1. 调用 Tx.checkPreimage 函数来保证传入的 txPreimage 确实是当前交易的 sighash preimage
  2. 从 txPreimage 中解析出当前 UTXO 锁定脚本的原文,从而得到代码段(黄色)和数据段(蓝色)的内容
  3. 根据当前状态计算正确的新状态
  4. 验证当前交易的输出:构造出我们期望的当前交易的所有输出,要求它们的 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 {

@state
int counter;

public function unlock(SigHashPreimage txPreimage, int outputAmount) {
// 1. verify preimage
//
require(Tx.checkPreimage(txPreimage));

// !!! no need to resolve locking script anymore !!!
//

// 2. process data directly
//
this.counter++;

// 3. verify transaction output
//
// !!! build the locking script for expected output with one-line code !!!
bytes expectedOutputScript = this.getStateScript();
// build expected output
bytes expectedOutput = Utils.buildOutput(expectedOutputScript, outputAmount);
// verify the current transaction output is the same as the expected output
require(SigHash.hashOutputs(txPreimage) == hash256(expectedOutput));
}
}

与 v1 相比,v2 代码没有任何逻辑上的变化,但不再需要手动处理脚本,简洁了很多:

  1. 验证 txPreimage
  2. 处理数据
  3. 验证当前交易的输出

注意到,这里使用的是 sCrypt int 类型来记录状态,它没有溢出风险。

我们可以不断调用 v2 合约,让计数器的值一直增加下去。如果部署合约时锁定了 5000 聪 BSV,每次调用都需要 2000 聪矿工费,那么在第三次调用时会余额(1000 聪)不足,这时候需要增加交易输入“为合约充值”。因为代码里没有对合约之外的其它交易输入做任何限制,所以你才能在调用合约的交易中添加其它输入。但注意,这些交易只能有 1 个输出。

在实际使用过程中,只允许交易有 1 个输出很不方便,因为不能在“充值调用”时添加找零,它会把所有其它输入中的金额都充值到合约中,或作为矿工费消耗掉。

v3

给 v2 合约新增一种解锁方法就可以解决这个问题。

contract CounterV3 {

@state
int counter;

public function increase(SigHashPreimage txPreimage, int outputAmount) {
require(Tx.checkPreimage(txPreimage));

this.counter++;

bytes expectedOutputScript = this.getStateScript();
bytes expectedOutput = Utils.buildOutput(expectedOutputScript, outputAmount);
require(SigHash.hashOutputs(txPreimage) == hash256(expectedOutput));
}

public function fund(SigHashPreimage txPreimage, int fundAmount, PubKeyHash changePkh, int changeAmount) {
require(Tx.checkPreimage(txPreimage));
// !!! ensure counter output has more satoshi than before !!!
// otherwise, it would be possible to "steal" money from the contract
require(fundAmount > 0);

this.counter++;

// output #0 is the new counter
int counterAmount = SigHash.value(txPreimage) + fundAmount;
bytes counterOutput = Utils.buildOutput(this.getStateScript(), counterAmount);
// output #1 is the p2pkh change
bytes changeOutput = Utils.buildOutput(Utils.buildPublicKeyHashScript(changePkh), changeAmount);
// expect current transaction has 2 outputs
bytes expectedOutputs = counterOutput + changeOutput;
require(SigHash.hashOutputs(txPreimage) == hash256(expectedOutputs));
}
}

我们把 v2 的 unlock 方法重命名成 increase 保留,用来兼容之前所有的交互方式,同时新增 fund 解锁方法,用来支持“充值调用”时的找零。

请注意,fund 解锁方法要求调用交易必须有 2 个输出:第一个输出是合约本身,第二个输出是交易找零。为了能在解锁时确定所有的交易输出,我们需要把 fundAmount、changePkh 和 changeAmount 都放到解锁脚本里。另外还要保证 fundAmount 的值大于 0,以避免在调用时从合约中“偷取”资金。

这个版本的代码看起来有些复杂,它有两种不同的解锁方式,你可以这么认为:sCrypt 会帮你把这些解锁方法都合并到一起,“智能”的构造出统一的包括所有逻辑的锁定脚本,不需要你费心。

v4

前三个版本的代码,都会在合约中锁定一定数量的 BSV。这可以用 SIGHASH_SINGLE 标记来优化,让合约只锁定最少数量的 BSV(满足 dust 限制即可)。

contract CounterV4 {

@state
int counter;

public function unlock(SigHashPreimage txPreimage) {
SigHashType sigHashType = SigHash.SINGLE | SigHash.FORKID;
require(Tx.checkPreimageSigHashType(txPreimage, sigHashType));

this.counter++;

bytes expectedOutputScript = this.getStateScript();
int outputAmount = SigHash.value(txPreimage); // the new counter has the same satoshi as the input
bytes expectedOutput = Utils.buildOutput(expectedOutputScript, outputAmount);
require(SigHash.hashOutputs(txPreimage) == hash256(expectedOutput));
}
}

新的 v4 代码与之前的合约相比,有两处不同:

  1. 调用 Tx.checkPreimageSigHashType 函数并传入指定的 sigHashType 来验证 preimage 的真实性
  2. 新的合约输出与之前锁定同样数量的 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 {

@state
int counter;

PubKeyHash creatorPkh;

public function increase(SigHashPreimage txPreimage) {
SigHashType sigHashType = SigHash.SINGLE | SigHash.ANYONECANPAY | SigHash.FORKID;
require(Tx.checkPreimageSigHashType(txPreimage, sigHashType));

this.counter++;

bytes expectedOutputScript = this.getStateScript();
int outputAmount = SigHash.value(txPreimage); // the new counter has the same satoshi as the input
bytes expectedOutput = Utils.buildOutput(expectedOutputScript, outputAmount);
require(SigHash.hashOutputs(txPreimage) == hash256(expectedOutput));
}

public function destroy(Sig sig, PubKey pk) {
// only the creator can destroy
require(hash160(pk) == this.creatorPkh);
require(checkSig(sig, pk));
}
}

在本例中,我们限制了只有部署合约的人(creatorPkh)才可以执行销毁,就像 P2PKH 一样,要求提供的签名和公钥能与 creatorPkh 相匹配。v5 合约在 BSV 测试网上的运行效果如下:

代码

总结

  • 要想实现“有状态”的合约就必须使用 preimage(作为参数传入解锁方法),因为要获取当前 UTXO 的锁定脚本,并限制新产生 UTXO 的锁定脚本
  • sighash preimage 中与交易输出有关的项只有 hashOutputs,它是所有交易输出(SIGHASH_ALL)或与当前输入相对应的交易输出(SIGHASH_SINGLE)的 SHA256 双哈希,也可能是 32 字节的 0x00(SIGHASH_NONE)
  • 因为 hashOutputs 是一个哈希值,所以我们必须要能在解锁方法中完全确定当前交易(需要被限制)的所有输出,才可以计算出期望的 hashOutputs,从而通过限制它跟 txPreimage 中 hashOutputs 的值保持一致来控制交易输出