在此之前,我们发布了一个基于 UTXO 的 token 方案,其中包括可替换 token和不可替换 token。像发行和转账这样的 token 规则都在层一由矿工来执行。但是,像 token 认证这样的额外规则,则需要在层二执行。这满足不了那些希望自己验证所有内容(如SPV)的用户的需求。我们设计了新的方案来解决这个问题,让所有的规则都可以在层一进行验证。
问题
有两种方法来假冒一个 token,如上图所示。每个框都表示一个 Tx,左侧是 input,右侧是 output。箭头从一个 Tx 指向它花费的 Tx。有相同 output 颜色的框包含相同的合约代码。
- 重放攻击:攻击者可以发行一个同样的 token。
- 中间人攻击:攻击者可以从一个和 token 无关的 UTXO 开始,从该点构造出一个假的 token 转账链。
在所有三种情况下,Tx 都会被矿工接受,因为这些 Tx 都通过了层一验证。每种情况的最后那个在红色框里的 Tx ,看上去是彼此相同的。判断它是否是合法的token A 的唯一方法就是回溯到发行 Tx。这可以通过两种方式来实现:
- 接收方自己进行所有验证。随着 token 转账链变长,这种方式很快会变得低效。
- 接收方信任第三方来进行验证,这破坏了层一 token 方案的无需信任优势。
解决方案
我们想出了一个方案来防止这两种攻击。
为了防止重放攻击,我们在每个 token UTXO 中用算法嵌入一个全局唯一的token ID。在token合约中,当发行 UTXO 被花费时,发行 Tx 的 txid 会被拷贝并作为 token ID 保留在后续所有的 token UTXO 中。任何一个新发行的token都会有不同的发行txid即不同的token ID,所以可以很容易检测出来。
当收到一个 Tx 时,为了防止中间人攻击,我们不仅要验证父 Tx ,还要验证祖父 Tx。当假冒的token UTXO(UTXO1)被花费到 UTXO(UTXO2),它会在层一被验证通过,因为token合约并没有被运行(请注意,一个UTXO的锁定脚本只有在被解锁时才会被运行),所以 Tx 会被接受。然而,当 UTXO2 被花费到 UTXO3 时,除 UTXO2 外,UTXO1 也会被验证。验证将会失败,因为 UTXO1 没有包含与 UTXO2 和 UTXO3 相同的合约,因此 Tx 也将会被拒绝。因此,在验证一个token Tx 时(如果我们收到 UTXO2),只需再回溯一步就可以了,而不需要像之前那样一直回溯。这极大简化了 token 的实现。
实现
下图是token合约参考实现,代码含义参考注释。它不仅验了父 Tx,还验证了祖父 Tx。
import "util.scrypt";
/**
* A non-fungible token enforced by miners at layer 1
*/
contract SPVToken {
// prevTx: tx being spent by the current tx
// prevPrevTx: tx being spent by prevTx
public function transfer(Sig senderSig, PubKey receiver, int satoshiAmount, SigHashPreimage txPreimage, bytes prevTx, bytes prevPrevTx) {
// constants
int TokenIdLen = Constants.TxIdLen;
int PrevTxIdIdx = 5;
int UnlockingScriptIdx = 41;
// uninitialized token ID
bytes NullTokenId = num2bin(0, TokenIdLen);
require(Tx.checkPreimage(txPreimage));
// read previous locking script: codePart + OP_RETURN + tokenID + ownerPublicKey
bytes lockingScript = SigHash.scriptCode(txPreimage);
int scriptLen = len(lockingScript);
// constant part of locking script: upto OP_RETURN
int constStart = scriptLen - TokenIdLen - Constants.PubKeyLen;
bytes constPart = lockingScript[: constStart];
PubKey sender = PubKey(lockingScript[constStart + TokenIdLen :]);
// authorize
require(checkSig(senderSig, sender));
bytes outpoint = SigHash.outpoint(txPreimage);
bytes prevTxId = outpoint[: TokenIdLen];
require(hash256(prevTx) == prevTxId);
bytes tokenId = lockingScript[constStart : constStart + TokenIdLen];
if (tokenId == NullTokenId) {
// get prevTxId and use it to initialize token ID
tokenId = prevTxId;
}
else {
/*
* validate not only the parent tx (prevTx), but also its parent tx (prevPrevTx)
*/
// TODO: assume 1 input, to extend to multiple inputs
bytes prevPrevTxId = prevTx[PrevTxIdIdx : PrevTxIdIdx + Constants.TxIdLen];
require(hash256(prevPrevTx) == prevPrevTxId);
int unlockingScriptLen = Util.readVarintLen(prevPrevTx[UnlockingScriptIdx :]);
// TODO: only validate output 0 here, to extend to multiple outputs
int lockingScriptIdx = UnlockingScriptIdx + unlockingScriptLen + Constants.InputSeqLen + 1 /* output count length */ + Constants.OutputValueLen;
bytes prevLockingScript = Utils.readVarint(prevPrevTx[lockingScriptIdx :]);
// ensure prev tx uses the same contract code
require(len(prevLockingScript) == len(lockingScript));
require(prevLockingScript[: constStart] == constPart);
// belongs to the same token
bytes prevTokenId = prevLockingScript[constStart : constStart + TokenIdLen];
require(prevTokenId == tokenId || prevTokenId == NullTokenId);
}
// validate parent tx
bytes outputScript = constPart + tokenId + receiver;
bytes output = Utils.buildOutput(outputScript, satoshiAmount);
require(hash256(output) == SigHash.hashOutputs(txPreimage));
}
}
总结
一般认为,基于 UTXO 的层一 token方案需要回溯历史 Tx 直到回溯至发行 Tx 。我们提出了一个突破方案,只需要回溯两步即可。该方案并不需要信任第三方,为更广泛的层一token普及铺平了道路。同样的技术也适用于其他智能合约,来避免对 Tx 历史的过多回溯。