try-catch
是一般 programming language 常見的錯誤處理機制,可以用來捕捉錯誤並進行處理,但是在 evm 這個設計之下出現了一些怪異的行為。要知道 try-catch
如何運作,首先需要知道錯誤如何傳遞出來。
Opcode overview - revert
§
作為錯誤處理最主要的 opcode,作用是將當前的 execution 停下,並從 memory region 取出一段資料作為 context 的回傳值。
require
§
最為常見的錯誤處理,只要提供的條件判斷為 false,就會將 reason string 作為錯誤拋出。而 reason string 不是以 String
型別拋出,是以 Error(String)
型別拋出。
編譯成 evm bytecode 之後,可以看到 revert
opcode 的存在,並從 memory region 裡面取出資料做拋出。
revert
§
在 Custom Error 出現之後越來越常被使用,主因是 Custom Error 不能和 require
搭配使用,且 revert
有向後兼容,所以 revert
也可以處理 reason string 並以 Error(String)
型別做拋出。
revert
處理 reason string 的編譯結果和 memory layout 與 require
處理 reason string 基本相同,所以以 Custom Error 為例。
assert
§
雖然幾乎不會用到,但是還是需要提一下。assert
主要用於處理 panic 和 invariants,會以 Panic(uint256)
作為錯誤型別拋出。
panic error code §
Solidity compiler 會在一些情況下將錯誤以 panic 的方式處理,這些情況在 docs 已經整理成一個表格如下:
code | description |
---|
0x00 | Used for generic compiler inserted panics |
0x01 | If you call assert with an argument that evaluates to false |
0x11 | If an arithmetic operation results in underflow or overflow outside of an unchecked { ... } block |
0x12 | If you divide or modulo by zero (e.g. 5 / 0 or 23 % 0 ) |
0x21 | If you convert a value that is too big or negative into an enum type |
0x22 | If you access a storage byte array that is incorrectly encoded |
0x31 | If you call .pop() on an empty array |
0x32 | If you access an array, bytesN or an array slice at an out-of-bounds or negative index (i.e. x[i] where i >= x.length or i < 0 ) |
0x41 | If you allocate too much memory or create an array that is too large |
0x51 | If you call a zero-initialized variable of internal function type |
example §
以下為觸發 division or modulo by zero 的範例:
測試和 Log 如下:
try-catch
§
總結一下會被拋出的錯誤有:Error(string)
, Panic(uint256)
, error CustomError()
。
再來回來看 try-catch
的行為。首先,try
這個關鍵字後面只能接「external function 的呼叫」或是「透過 new
關鍵字去建立一個新的合約」
呼叫後的回傳的資料會透過 returncodecopy opcode 存入 memory region。接著 catch
關鍵字後面會附上錯誤資訊的型別並將存入 memory region 的資料做 abi decode,最後由後面的邏輯處理。以下為例,一個用來捕捉 Error(string)
,另一個用來捕捉 Panic(uint256)
:
而 catch
沒有支援捕捉 custom error
如果拋出的錯誤不是 Error(string)
或是 Panic(uint256)
,可以寫一個 default catch 做捕捉。default catch 有兩種寫法:catch (bytes memory data) {...}
和 catch {...}
。這兩種寫法的差異只在於需不需要錯誤的資訊而已。
被遺忘的 Custom Error 則可以在 catch (bytes memory){}
中以 bytes memory
型別被捕捉,開發者可以自行做 abi decode 處理,以下為 try catch 的整體結構:
or
try-catch
disadvantage §
try-catch 不好用的原因之一是沒有辦法捕捉 custom error 前面已經提過了;另外一個原因就是就算用了 try-catch 也還是有捕捉不了的錯誤
這裡舉例兩個會讓 try-catch
無法按照預期捕捉錯誤的情況:
reason 1: decode issue §
先前提到 try-catch
會對 revert 回傳的資料做 abi decode,但是如果 decode 的過程中發生錯誤時,錯誤不會在原本預計的 catch block 被捕捉。
以下合約會以 "cat"
作為錯誤資訊。isCorrectLen
會調整 revert 回傳的資料長度,正確的回傳長度為 71(0x47),長度小於 71 則會使 abi decode 發生錯誤:
測試和 log 如下,因為 decode 成 Error(string)
中出現錯誤,所以只能以 bytes memory
的型別被捕捉:
reason 2: return bomb §
return bomb 也是一個有趣的議題。在 abi decode 之前,需要將 revert 回傳的資料儲存在 memory region 裡面,而存取超出當前 memory region 範圍的資料時,則會觸發 memory expansion 去擴展 memory region 的範圍。memory expansion 是需要消耗 gas,如果 revert 回傳的資料過於龐大,則會消耗掉大量的 gas 並讓交易 revert 掉。所以如果要嘗試去捕捉未知合約發出來的錯誤,是有可能捕捉到一顆 gas bomb 的。
以下以給定的 revert length 和 gas 為例 (主要從 karam 的範例做修改):
測試和 Log 如下:
從 test_case1
的 log 可以看出,external call 的執行是成功的,但是 revert 回傳的資料長度觸發 memory expansion 將所有的 gas 都消耗掉了。而從 test_case2
可以看出,default branch catch {}
並不會將 revert 回傳的資料存入 memory region 也不會對其做 abi decode。
Conclusion §
回顧一下 try-catch
做了什麼事:
- 呼叫外部合約
- 如果需要處理錯誤資訊,則將資料存入 memory region (可能是 success 或是 revert)
- 將資料 decode 之後由 try block 或是 catch block 處理
現在越來越多的合約都轉向使用 Custom Error 來節省 gas 開銷,只能針對 external function 但又沒辦法按照預期捕捉 Custom Error 的 try-catch
用起來就不是那麼方便。
如果只是單純不要讓交易 revert,這樣寫 try Call() catch {}
是可行的,不需要處理 revert 回傳的資料,就不會有 returndatacopy 造成 return bomb。但是如果要處理 revert 回傳的資料,請用在可信任的合約或是在 protocol 內部做錯誤處理。
Reference §