前言
水龙头是什么
水龙头这个名字总会让我想起生活中把水龙头开关拧到无限接近但又不达到关闭状态的大妈们,这样可以让水一滴一滴缓慢滴出,但又不会触发水表计费,彰显出她们丰富的生活经验和生存智慧。
水龙头是赠送小额比特币的服务。
为了让用户可以快速尝试Bitcoin SV网络,会有人搭建水龙头服务,给用户赠送给小额比特币,这样用户就可以用这些币尝试使用Bitcoin SV网络,如:转账、测试、写入数据等。
——wiki.bsv.info
合约需求
本文介绍如何通过智能合约直接在链上提供水龙头服务。该服务满足如下需求:
- 任何人都可以向合约中充值。
- 每隔一段时间,任何人都可以从合约中提取一定额度的BSV。
完整的代码已经合入了sCrypt的样板项目中。
准备知识
阅读本文前需要先了解OP_PUSH_TX的相关知识,推荐阅读如下文章:
代码分析
总体结构
该合约共有三个部分:
- 充值合约
FaucetDeposit
- 提现合约
FaucetWithdraw
- 水龙头合约
Faucet
对外可见的是水龙头合约,其他两个合约不可见,只是帮助水龙头合约实现具体功能。顾名思义,充值合约实现充值功能,提现合约实现提现功能。虽然定义了多个合约,但最终编译出来的脚本还是放在一个UTXO中。这种多合约模式的使用方法和特点,可以参见sCrypt的说明文档多合约。
水龙头合约对外提供两个函数:
- 充值
deposit
- 提现
drop
充值功能分析
函数参数
SigHashPreimage preImage
:当前tx的签名哈希原像。如果你不知道这个参数的含义,请阅读文章开始部分推荐的文章。int depositAmount
:充值聪数。Ripemd160 changePKH
:找零用的公钥Hash。int changeAmount
: 找零聪数。
参数检查
require(Tx.checkPreimage(preImage));
require(depositAmount > 0);
对参数进行取值范围的检查。
因为sCrypt目前还不支持unsigned int
类型,所以需要检查depositAmount
参数是正数,避免出现利用充值函数从合约中取走币的漏洞。
构造合约输出
合约规定充值tx最多会有两个有先后顺序的输出:
- 充值后的合约
- 找零(可选)
bytes output0 = this.composeOutput0(preImage, depositAmount);
function composeOutput0(SigHashPreimage preImage, int depositAmount):bytes{
bytes lockingScript = Util.scriptCode(preImage);
int contractTotalAmount = Util.value(preImage) + depositAmount;
return Util.buildOutput(lockingScript, contractTotalAmount);
}
充值前合约里的余额加上要充值的额度就是充值后的合约里的余额。这里你就可以理解为什么要检查depositAmount
是正数了。
充值不会改变合约的脚本,所以用上一个合约的脚本和充值后的余额就可以拼出充值后的合约输出。
构造找零输出
bytes output1 = this.composeOutput1(changePKH, changeAmount);
function composeOutput1(Ripemd160 changePKH, int changeAmount):bytes{
bytes output1 = b'';
if(changeAmount > 546){
output1 = Util.buildOutput(Util.buildPublicKeyHashScript(changePKH), changeAmount);
}
return output1;
}
如果找零额度小于546聪,则认为不需要找零,也就没有找零输出。
根据找零PKH构造出P2PKH格式的输出脚本,再结合找零额度,就可以构造出完整的输出。
校验所有输出的哈希值
Sha256 hashOutputs = hash256(output0 + output1);
require(hashOutputs == Util.hashOutputs(preImage));
提现功能分析
函数参数
SigHashPreimage preImage
:当前tx的签名哈希原像。Ripemd160 receiverPKH
:接收者的公钥哈希。
参数检查
require(Tx.checkPreimage(preImage));
require(Util.nSequence(preImage) < 0xFFFFFFFF);
为了满足两次提现之间的时间间隔,需要使用比特币的nLocktime
功能。设置了nLocktime
的值后,能够限制合约在该时间之前被花费,也就阻止了在该时间之前发起下一次提现转账。
要让nLocktime
生效,则需要让合约输入的nSequence
小于0xFFFFFFFF。
手续费和提现金额
int withdrawAmount = 2000000;
int fee = 3000;
我们简单粗暴地把每次赠送的数额设置为0.02BSV,不能多也不能少。
我们同样简单粗暴地把每次转账的费用设置为3000聪,基本上相当于0.5聪每字节。
构造合约输出
合约规定提现tx最多会有两个有先后顺序的输出:
- 合约输出
- 赠送输出(可选)
bytes output0 = this.composeOutput0(preImage, withdrawAmount, fee);
function composeOutput0(SigHashPreimage preImage, int withdrawAmount, int fee):bytes{
bytes prevLockingScript = Util.scriptCode(preImage);
int scriptLen = len(prevLockingScript);
int fiveMinutesInSecond = 300;
int newMatureTime = this.getPrevMatureTime(prevLockingScript, scriptLen) + fiveMinutesInSecond;
require(Util.nLocktime(preImage) == newMatureTime);
bytes codePart = this.getCodePart(prevLockingScript, scriptLen);
bytes script = codePart + pack(newMatureTime);
int amount = Util.value(preImage) - withdrawAmount - fee;
return Util.buildOutput(script, amount);
}
合约输出的数据部分是一个四字节的时间戳,也就是matureTime
,该值与tx的nLocktime
相等,表示合约在该时刻之后才可以被花费(充值或提现)。
合约中记录matureTime
的目的是为了记住合约所在的上一个tx的nLocktime
,从而可以用该值对当前tx的nLocktime
进行校验。
合约设计成每隔5分钟可以被花费一次,那么matureTime
的值每次都会增加300秒,保证nLocktime
的值也是按照该规律增加,从而最终保证每次花费合约之间的间隔为5分钟。
老合约的余额减去赠送的数额,再减去手续费,就是新合约里的余额。脚本部分和余额部分组合在一起形成新合约的输出。
构造提现输出
bytes output1 = this.composeOutput1(receiverPKH, withdrawAmount);
function composeOutput1(Ripemd160 receiver, int withdrawAmount):bytes{
bytes script = Util.buildPublicKeyHashScript(receiver);
return Util.buildOutput(script, withdrawAmount);
}
检查所有输出的哈希值
Sha256 hashOutputs = hash256(output0 + output1);
require(hashOutputs == Util.hashOutputs(preImage));
总结
测试网络上已经部署了该合约:
感谢晓峰大爷提供测试网络的BSV。