如何写安全的智能合约?

Solidity was started in October 2014 when neither the Ethereum network nor the virtual machine had any real-world testing, the gas costs at that time were even drastically different from what they are now. Furthermore, some of the early design decisions were taken over from Serpent. During the last couple of months, examples and patterns that were initially considered best-practice were exposed to reality and some of them actually turned out to be anti-patterns. Due to that, we recently updated some of the Solidity documentation, but as most people probably do not follow the stream of github commits to that repository, I would like to highlight some of the findings here.

Solidity自2014年10月份开始,不论是以太坊的网络还是虚拟机都经历了真实世界的考验,现在gas的消耗已经和当初有非常大的变化。而且,一些早期的设计决定已经从Serpent中被替换掉。在过去的几个月,一些最初被认为是最佳实践的例子和模式,已经被验证可行,而有些被证实为反模式。因为上述原因,我们最近更新了一些Solidity的文档,但是大多数人并没有一直关注git的提交,我会在这里重点描述一些结果。

I will not talk about the minor issues here, please read up on them in the documentation.

我不会在这里讨论小的问题,大家可以阅读文档来了解它们。

Sending Ether
Sending Ether is supposed to be one of the simplest things in Solidity, but it turns out to have some subtleties most people do not realise.
It is important that at best, the recipient of the ether initiates the payout. The following is a BAD example of an auction contract:

发送Ether
发送ether应该是Solidity里最简单的事情之一,但是它有一些微妙之处多数人都没有意识到。重要的是,最好的办法是,由ether的收款人发起支付。以下是一个关于拍卖的不好的例子。

// THIS IS A NEGATIVE EXAMPLE! DO NOT USE!
contract auction { 
  address highestBidder;
  uint highestBid;
  function bid() { 
    if (msg.value < highestBid) throw;
    if (highestBidder != 0) 
      highestBidder.send(highestBid); // refund previous bidder 
    highestBidder = msg.sender;
    highestBid = msg.value; 
  }
}

Because of the maximal stack depth of 1024 the new bidder can always increase the stack size to 1023 and then call bid()
which will cause the send(highestBid)
call to silently fail (i.e. the previous bidder will not receive the refund), but the new bidder will still be highest bidder. One way to check whether send
was successful is to check its return value:

因为最大的调用栈深度是1024,一个新的投标者可以一直增加调用栈到1023,然后调用bid(),这样就会导致send(highestBid)调用被悄悄地失败(也就是前一个投标者没有收到返回金额),但是现在新的投标者仍然是最高的投标者,检查send是否成功的一个方法是,检查它的返回值:

/// THIS IS STILL A NEGATIVE EXAMPLE! DO NOT USE!
if (highestBidder != 0) 
  if (!highestBidder.send(highestBid)) throw;

The throw statement causes the current call to be reverted. This is abad idea, because the recipient, e.g. by implementing the fallback function as function() { throw; }
can always force the Ether transfer to fail and this would have the effect that nobody can overbid her.

throw语句引起当前的调用回滚。这是一个糟糕的主意,因为接受方,如果实现了 fallback 函数function() { throw; }总是能强制ether转移失败,然后就会导致没有其它人可以报价高于它。

The only way to prevent both situations is to convert the sending pattern into a withdrawing pattern by giving the recipient control over the transfer:

唯一的防止这两种情况的办法是,转换发送模式为提款模式,使收款方控制以太币转移:

/// THIS IS STILL A NEGATIVE EXAMPLE! DO NOT USE!
contract auction { 
  address highestBidder;
  uint highestBid;
  mapping(address => uint) refunds;
  function bid() { 
    if (msg.value < highestBid) throw;
    if (highestBidder != 0) 
      refunds[highestBidder] += highestBid;
    highestBidder = msg.sender;
    highestBid = msg.value;
  } 
  function withdrawRefund() {
    if (msg.sender.send(refunds[msg.sender])) 
      refunds[msg.sender] = 0;
  }
} 

Why does it still say “negative example” above the contract? Because of gas mechanics, the contract is actually fine, but it is still not a good example. The reason is that it is impossible to prevent code execution at the recipient as part of a send. This means that while the send function is still in progress, the recipient can call back into withdrawRefund. At that point, the refund amount is still the same and thus they would get the amount again and so on. In this specific example, it does not work, because the recipient only gets the gas stipend (2100 gas) and it is impossible to perform another send with this amount of gas. The following code, though, is vulnerable to this attack:msg.sender.call.value(refunds[msg.sender])()
.

为什么说上面的合约依然是“负面的例子”?因为gas机制,合约实际上是没问题的,但是它依然不是一个好的例子。因为它不能阻止代码执行,在收款方参与send时。这意味着,当send函数在进行时,收款方可以返回调用withdrawRefund。在这时,返还金额仍然是一样的,因此他们可以再次获得金额。在这个特殊的例子里,它不能如此,因为收款方只有一定的gas额度(2100 gas),它不可能用这么多gas来执行另外一次send。但是以下的代码就可以被攻击:msg.sender.call.value(refunds[msg.sender])()

Having considered all this, the following code should be fine (of course it is still not a complete example of an auction contract):

经过考虑到上面的情况,下面的代码应该是没有问题的(当然它仍然不是一个完整的拍卖合约的例子):

contract auction { 
  address highestBidder;
  uint highestBid;
  mapping(address => uint) refunds;
  function bid() { 
    if (msg.value < highestBid) throw;
    if (highestBidder != 0) 
      refunds[highestBidder] += highestBid; 
    highestBidder = msg.sender; 
    highestBid = msg.value;
  } 
  function withdrawRefund() { 
    uint refund = refunds[msg.sender];
    refunds[msg.sender] = 0;
    if (!msg.sender.send(refund)) 
      refunds[msg.sender] = refund;
  }
}

Note that we did not use throw on a failed send because we are able to revert all state changes manually and not using throw has a lot less side-effects.

注意我们不使用throw在失败的send函数上,因为我们可以手动的回滚所有的状态变化,而不需要使用throw导致很多副作用。

Using Throw
The throw statement is often quite convenient to revert any changes made to the state as part of the call (or whole transaction depending on how the function is called). You have to be aware, though, that it also causes all gas to be spent and is thus expensive and will potentially stall calls into the current function. Because of that, I would like to recommend to use it only in the following situations:

使用Throw
Throw字句可以经常十分方便地回滚任何状态上的变化作为方法调用的一部分(或许整个交易都依赖于这个函数如何调用)。尽管如此,你必须明白,它也可以导致所有的gas被消耗,因此它很昂贵,而且会停止调用当前的函数。因此,我推荐只在下面这几种情况下使用only

1. Revert Ether transfer to the current function
If a function is not meant to receive Ether or not in the current state or with the current arguments, you should use throw to reject the Ether. Using throw is the only way to reliably send back Ether because of gas and stack depth issues: The recipient might have an error in the fallback function that takes too much gas and thus cannot receive the Ether or the function might have been called in a malicious context with too high stack depth (perhaps even preceding the calling function).

1. 回滚发送到当前函数的ether
如果一个函数不是为了接受ether或者不在当前状态或者不是当前参数,你应该使用throw来拒绝ether。使用throw是唯一的可靠的办法来返还ether,因为gas和调用栈深度的问题:收款方可能在fallback函数里存在错误,造成消耗太多gas而无法收到ether或者在函数调用时,在一个充满恶意的包含很深调用栈的上下文中(或许甚至在执行这个函数之前)。

Note that accidentally sending Ether to a contract is not always a UX failure: You can never predict in which order or at which time transactions are added to a block. If the contract is written to only accept the first transaction, the Ether included in the other transactions has to be rejected.

记住偶然发送ether到一个合约失败并不总是用户体验错误:你无法预测在哪种顺序下或者在何时transaction会被加到block中。如果合约被写成只接受第一个transaction,包含在其它transactions里的ether必须被拒绝。

2. Revert effects of called functions
If you call functions on other contracts, you can never know how they are implemented. This means that the effects of these calls are also not know and thus the only way to revert these effects is to use throw. Of course you should always write your contract to not call these functions in the first place, if you know you will have to revert the effects, but there are some use-cases where you only know that after the fact.

2. 回滚已经调用过的函数结果
如果你调用函数在其它合约上,你永远不知道他们是如何执行的。这意味着,这些调用的结果也无法知道,因此唯一回滚这些结果的办法是throw。当然你也可以使你的合约不在第一时间调用这些函数,如果你知道必须要回滚这些结果,但是有一些例子说明你只有在这些事实发生之后才能知道这些结果。

Loops and the Block Gas Limit
There is a limit of how much gas can be spent in a single block. This limit is flexible, but it is quite hard to increase it. This means that every single function in your contract should stay below a certain amount of gas in all (reasonable) situations. The following is a BAD example of a voting contract:

循环和块gas限制
在一个块里使用gas是有一个限制的。这个限制是动态的,但是很难去增长它。这意味着在你的合约里的每一个函数调用,在所有(合理的)情况下应该保持在低于某一个特定的gas数量。以下是一个关于投票的糟糕例子:

/// THIS IS STILL A NEGATIVE EXAMPLE! DO NOT USE!
contract Voting { 
  mapping(address => uint) voteWeight;
  address[] yesVotes;
  uint requiredWeight;
  address beneficiary;
  uint amount;
  function voteYes() {
    yesVotes.push(msg.sender);
  } 
  function tallyVotes() { 
     uint yesVotes; 
     for (uint i = 0; i < yesVotes.length; ++i) 
        yesVotes += voteWeight[yesVotes[i]]; 
     if (yesVotes > requiredWeight) 
        beneficiary.send(amount);
  }
}

The contract actually has several issues, but the one I would like to highlight here is the problem of the loop: Assume that vote weights are transferrable and splittable like tokens (think of the DAO tokens as an example). This means that you can create an arbitrary number of clones of yourself. Creating such clones will increase the length of the loop in the tallyVotes function until it takes more gas than is available inside a single block.

这个合约实际上有几个问题,但是我想在这里强调的是关于循环的问题:假设投票的权重是可以转移和分割的,就像tokens(就像DAO tokens那样)。这意味着,你可以创建任意多个你自己的克隆。创建这样的克隆会增加tallyVotes函数里的循环,至到它消耗超过在一个单独块里可用gas额度。

This applies to anything that uses loops, also where loops are not explicitly visible in the contract, for example when you copy arrays or strings inside storage. Again, it is fine to have arbitrary-length loops if the length of the loop is controlled by the caller, for example if you iterate over an array that was passed as a function argument. But never create a situation where the loop length is controlled by a party that would not be the only one suffering from its failure.

这种情况适用于所有使用循环的情况,同样包括那些隐晦的循环,比如说当你拷贝storage里一个数组或者字符串时。另外,如果循环的长度被调用者控制,有任意长度的循环也是没有问题的,例如你遍历一个被当参数传递进来的数组。但是永远不要造成这种情况,让遍历被某一方控制,但是他又不能承受遍历失败。

As a side note, this was one reason why we now have the concept of blocked accounts inside the DAO contract: Vote weight is counted at the point where the vote is cast, to prevent the fact that the loop gets stuck, and if the vote weight would not be fixed until the end of the voting period, you could cast a second vote by just transferring your tokens and then voting again.

另外,这也是为什么我们在DAO合约里有冻结帐户的概念:投票权重在投票进行时就进行了计算,为了防止循环被卡住,如果在投票结束后,投票权重没有被满足,我们可以进行第二轮投票,只需要转移你的tokens然后再次投票即可。

Receiving Ether / the fallback function
If you want your contract to receive Ether, you have to make its fallback function cheap. It can only use 2300, gas which neither allows any storage write nor function calls that send along Ether. Basically the only thing you should do inside the fallback function is log an event so that external processes can react on the fact.

接受ether/fallback函数
如果你想要你的合约接受ether,你必须使fallback函数便宜。它只能使用2300gas,既不允许任何storage写入也不允许function调用任何其它ether发送。基本上,你只需要在fallback函数里log下event,这样外部的调用可以被反应到事实上。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容