智能合约设计模式 - 拉代替推
本文为智能合约设计模式系列的一部分。
目的
把转账风险从合约端移到客户端
动机
在以太坊中发送以太币需要呼叫接受地址。有几个原因可能导致此调用失败,如果接受地址是一个合约,它可能实现了一个fallback方法,一旦被调用就抛出异常。另个原因是gas耗尽,这可能发生在一个方法中进行大量外部调用时,例如,将赌注的利润发送给多个赢家。由于这些原因,开发者应该遵循一个简单原则:不要相信外部调用不会报错。大多数情况下,这没有问题,因为,接受者有责任确保能够收到钱,如果没有收到,只是他的损失。但下面的拍卖合约例子展示了一个例外,即使单个接受者出错也可能冻结整个合约。
// THis code contains deliberate errors. Do not use.
contract BadAuction {
address highestBidder;
uint highestBid;
function bid() public payable {
require(msg.value >= highestBid);
if (highestBidder != 0) {
highestBidder.transfer(highestBid);
}
highestBidder = msg.sender;
highestBid = msg.value;
}
}
一旦某个无法接受以太币转账的地址(例如fallback方法需要比发送更多的gas,详细信息参考安全转账模式)占据最高出价者,则合约处于无法工作的状态。新的高竞价都会触发第11行代码而导致异常,从而无法竞价。
另个潜在问题是一个方法试图发送以太币给多个接收人,只要其中一个发送失败,就会导致所有已完成的发送撤销并停止执行剩下的发送。
为了解决这些限制,一种技术被提出 - 隔离开每个外部调用,并将失败的风险从合约转移到用户。由于隔离了发送,某次调用的执行都不会影响其他发送或合约逻辑。
适用性
在以下条件时使用拉代替推模式
- 希望一个方法处理多个以太币转账。
- 希望避免承担以太币转账的风险。
- 用户有自己提取以太币的激励。
参与者和协作
本模式有三个参与者。首先,转账发起人(例如合约的所有人或合约本身)启动流程。其次,智能合约负责记录余额。第三个参与者是接收者,不是等待接收资金,而是主动请求提款,以便将此操作与其它合约逻辑隔离开。
实现
为了将外部调用同其它调用以及合约逻辑隔离开,拉代替推模式通过让用户提取(拉)以太币代替发送(推)以太币给用户,而将转账风险转移给用户。这个模式的核心是一个维护用户可用提款金额的映射。在映射中添加一个记录代替真正的转账,如果记录已存在,就在现有记录上增加金额。用户现在负责调用智能合约的提款方法来取回资金,该方法使用检查效果交互模式在实际转账前更新可用余额。
这样实现,某个转账的异常只会影响它自己,而不会影响整个转账甚至如上例所示整个合约。
代码示例
下面代码展示了拉代替推模式的实现,只包含必要部分。
// safety or correctness. Use at own risk.
contract PullOverPush {
mapping(address => uint) credits;
function allowForPull(address receiver, uint amount) private {
credits[receiver] += amount;
}
function withdrawCredits() public {
uint amount = credits[msg.sender];
require(amount != 0);
require(address(this).balance >= amount);
credits[msg.sender] = 0;
msg.sender.transfer(amount);
}
}
第4行的credits
映射是这个模式的关键元素之一,它保存每个地址的可用提款金额。提款发生在第6行的allowForPull
方法中。它代替直接转账到用户地址。因此,将调用allowForPull
方法而不是<address>.transfer
。这个方法带有private
修饰符,只能在合约内调用。如果想从外部访问,应使用public
修饰符和访问限制模式,确保只有授权的地址才可以修改可用提款额度。
用户调用第10行的drawCredits
方法发起提款。第11行取出调用者的提款额度。第13行确保用户提款额度大于0(无符号整数不能为负,只需检查是否为0)。第13行检查合约余额是否足够支付,不过不够,后面的转账操作就会失败,这个检查不是必须的,但是尽早发现失败是一个好的实践。第16行设置用户提取额度为0,然后进行转账,符合检查效果交互模式避免重入攻击。最后,第17行将取款推至用户地址。
结果
拉代替推模式是一个好办法缓解同时多个转账中可能出现的问题。由于隔离了每个转账,一个错误不会影响其它转账。此外,改由用户负责确保能够收到以太币。
但是,它也会带来负面影响。与采用拉代替推模式合约交互时,需要发送一个额外事务,即请求提款事务。这不仅会导致额外的交易成本,也会损害用户体验。用户应该同合约只进行必要的交互,特别是针对没有经验的用户,否则他们可能会犯错。在一个案例中,合约所有者称超过10%的用户在许可后的7天内都没有提款。这说明这个模式适合所有参与者都具有强烈的提款动机的场景。否者,如果提款很复杂或者不值得,用户可能会考虑竞品或放弃使用。
选用这个模式要权衡安全性和便利性。实现它之前,应评估影响的用户体验是否可控,以及灵活使用安全转账模式是否已经足够。
已知应用
一个流行例子是OpenZeppelin的拉支付合约。它以通用方式实现,其它合约可以通过继承使用它的功能。
BlockParty合约是个更专业的实现,这是一个管理免费活动出席押金的合约。用户只有出席了他注册的活动后才可以取回押金。合约所有者一般也是活动组织者发送一个包含用户地址的确认交易后,出席者就可以取回押金。
推而广之
这个模式适用于所有链的智能合约或去中心化应用开发。区块链系统中,为了确保去中心化,事务调用需要保持一致性。事务中的一个步骤出错,整个事务就不会生效。对于重要操作,例如转账但不限于此,采用推方式的事务调用需要执行操作多次,一旦其中一个出错,其它所有操作都不会生效。因此,采用包含少量操作的拉方式事务
成为更好的选择。
另外区块链天然不适合运行复杂的方法,过长的运行时间会导致出块变慢和延迟增加,目前区块链基础架构还无法有效的利用并发提高运算速度。这也导致了需要采用操作简单的拉模式代替操作复杂的推模式,例如,在区块链上实现资金盘分红这种复杂功能,只能采用拉模式。
完整内容请查看智能合约设计模式系列