如何寫安全的智能合約?

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閱讀 227,837評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,196評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,688評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,654評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,456評論 6 406
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,955評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,044評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,195評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,725評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,608評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,802評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,318評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,048評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,422評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,673評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,424評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,762評論 2 372

推薦閱讀更多精彩內容