本文是Solidity开发模式系列第一篇
确保安全转账以太币
和比特币相比,转账不是以太坊的主要应用,但它仍是一个必要的、大量使用的功能。外部账户转账可以很容易的通过网络交易完成,但合约账户的转账不是那么直接。
Solidity早期,从合约转账到另一个地址,无论它是外部账户或合约账户,都是.send(amount)
方法,它发送指定以太币给指定地址。然而,send
方法不能返回错误且只使用少量gas。人们采用诸如调用.call.value(amount)()
方法的办法克服这一限制,此方法通过.gas(amountOfGas)
指定gas。很快,人们发现它导致一个新的攻击,重入攻击,导致大量以太币被盗。为了给这个方案起个名字并抛出异常,Solidity版本0.4.13引入一个新方法transfer(amount)
。 它并不能指定gas数量,使其更类似send
方法而非call.value
方法。
用户有三种方式从合约转出以太币,每种方法都有不同的应用场景。本模式的目的是介绍界定不同的方式,并根据具体的需求,给出应采用哪种方式的建议。
在以下条件时使用安全转账模式
此模式的参与者是发送以太币的合约以及接收地址,它可以是一个外部账户也可以是一个合约。模式在发送合约总实现,但接收地址也起着至关重要的作用,特别是如果它是一个合约,因为如果gas足够,它有可能恶意重入发送合约。
要决定在特定场景中使用哪个方法,首先要了解这三种方法的差异。需要考虑两个维度:gas数量和异常传播。send
和transfer
方法使用2300gas,刚好在接收合约中记录一个事件。如果需要大量gas处理接收,就必须使用call.value
方法,因为它会使用所有gas,除非在.gas()
参数指定使用量。关于异常传播,send
和call.value
类似,它们不会抛出异常,而是返回false
。但是,transfer
方法将抛出异常到发送合约,并自动恢复所有状态改变。前两种方法的异常传播可以通过使用守卫检查模式的变通方法实现。例如:require(.send(amount))
等同于.transfer(amount)
。下表概述了三种方法的差异:
Function | Amount of Gas Forwarded | Exception Propagation |
---|---|---|
send | 2300 (not adjustable) | false on failure |
call.value | all remaining gas (adjustable) | false on failure |
transfer | 2300 (not adjustable) | throws on failure |
三个方法也有相似之处:它们都是地址数据类型的方法,意味着它们必须跟在一个地址后面,其中地址是接收者,执行合约是发送者。另一个相似之处是,要转移的金额以wei (1 wei = 10^-18以太币}为单位。最后,三种方法都被Solidity编译器转换成CALL
操作码,表明它们使用相同的内部过程。
下面的示例展示了两个合约:第一个合约接收转账,第二个合约使用三种方法发送转账,并展示了处理异常的不同方式。
// This code has not been professionally audited, therefore I cannot make any promises about
// safety or correctness. Use at own risk.
contract EtherReceiver {
function () public payable {}
}
contract EtherSender {
EtherReceiver private receiverAdr = new EtherReceiver();
function sendEther(uint _amount) public payable {
if (!address(receiverAdr).send(_amount)) {
//handle failed send
}
}
function callValueEther(uint _amount) public payable {
require(address(receiverAdr).call.value(_amount).gas(35000)());
}
function transferEther(uint _amount) public payable {
address(receiverAdr).transfer(_amount);
}
}
第一个合约EtherReceiver
的唯一工作就是接收以太币。因此,fallback方法带有payable
修饰符,此方法将实现恶意代码进行重入攻击。由于重入攻击需要多于2300gas,只有call.value
方法才可以触发攻击。从第8行起,第二个合约EtherSender
给EtherReceiver
的实例receiverAdr
(第10行)转账。
下面三个方法使用不同方式转账,并都带有payable
修饰符,以便发送以太币。此外,它们都有一个以wei为单位的输入参数,指明转账金额。调用它们的事务金额至少是转账金额。第12行的sendEther
方法使用send
方法,由于错误不会被抛出,我们可以处理返回值,例如IF子句。
第18行的第二个方法callValueEther
封装在一个require
语句中,以展示如何抛出异常,即使对于不支持的方法也适用。在这个例子中,我们通过.gas(35000)
指定接收合约的回退方法使用35000的gas。如果回退方法实现了一些高级逻辑,这将非常有用。
第22行的transferEther
方法有一个直接实现,不需要其它语句,因为异常会自动抛出。
transfer
和send
是安全的,可防止重入,因为它们只提供2300gas。大多数情况下,优先使用transfer
方法,因为它会在出错时自动恢复。send
方法是transfer
的低级定价方法,可用在处理错误但不想恢复状态改变时。call.value
只能作为最后的手段,因为它破坏了Solidity的安全性。它的一个应用领域是转账给需要大量gas的合约,它可以为诚实和有经验的用户提供很大的灵活性,但也会被攻击者利用。
一方面,三种转账方法提供了灵活性,简单的transfer
方法可用于大多数情况,而复杂的call.value
可用于专门的任务。另一个方面,不同的方法会令开发人员和用户困惑,因为它们的命名完全无法体现它们的区别。
另一个重要结果是,受限的转账gas对上下文逻辑的影响。例如,如果状态机(参阅状态机模式)进入下一阶段依赖于成功的转账给一个特定合约,则应确保这个特定合约能够接收以太币。如果回退方法需要更多的gas,使用transfer
方法会导致合约冻结。以太君主合约就有这个问题,如果它使用transfer
而非send
,将处于一个无解的状态。可以使用拉代替推模式克服这一限制,它允许用户请求付款代替主动发送,以避免意外行为。
这个模式的多种实现方法可以在MultiSend合约中看到,它允许只使用一个初始事务将以太币发送到多个地址。合约然后根据用户调用使用transfer
或call.value
方法向接收者发起多个内部事务。另一个例子是加密精灵合约,一个基于加密猫的第三方游戏。整个合约只使用了transfer
方法,因为它安全且易于使用,并且已能满足合约要求。
更多Solidity开发模式