try-catch 是一般 programming language 常見的錯誤處理機制,可以用來捕捉錯誤並進行處理,但是在 evm 這個設計之下出現了一些怪異的行為。要知道 try-catch 如何運作,首先需要知道錯誤如何傳遞出來。

Opcode overview - revert

作為錯誤處理最主要的 opcode,作用是將當前的 execution 停下,並從 memory region 取出一段資料作為 context 的回傳值。

assembly {
revert(offset, size)
}

Require

最為常見的錯誤處理,只要提供的條件判斷為 false,就會將 reason string 作為錯誤拋出。而 reason string 不是以 String 型別拋出,是以 Error(String) 型別拋出。

require(condition, "reason string")

編譯成 evm bytecode 之後,可以看到 revert opcode 的存在,並從 memory region 裡面取出資料做拋出。

// 編譯後為 `revert(offset, size)`
// memory layout
[offset] : 0x08c379a0 // bytes4, error selector of `Error(String)`
[offset+4] : 0x20 // bytes32, string's offset (it is a reference type)
[offset+24]: size // bytes32, string length
[offset+44]: 0x... // hex value of "reason string"

Revert

在 Custom Error 出現之後越來越常被使用,主因是目前 Custom Error 不能和 require 搭配使用,且 revert 有向後兼容,所以 revert 也可以處理 reason string 並以 Error(String) 型別做拋出。

revert 處理 reason string 的編譯結果和 memory layout 與 require 處理 reason string 基本相同,所以以 Custom Error 為例。

// revert with custom error
error CustomError(string, uint256);
if (condition) revert CustomError("Oops!", 5);
// 編譯後為 `revert(offset, size)`
// memory layout
[ptr] : 0x.... // error selector of CustomError();
[ptr+4] : 0x40 // bytes32, String's offset (it is a reference type)
[ptr+24]: 0x05 // uint256
[ptr+44]: string_size // string length
[ptr+64]: string_data // hex data of string

Assert

雖然幾乎不會用到,但是還是需要提一下。assert 主要用於處理 panic 和 invariants,會以 Panic(uint256) 作為錯誤型別拋出。

panic error code

Solidity compiler 會在一些情況下將錯誤以 panic 的方式處理,這些情況在 docs 已經整理成一個表格如下:

以下為觸發 division or modulo by zero 的範例:

contract ErrorEmittor {
function emitPanic(
uint256 a,
uint256 b
) external pure returns (uint256 c) {
// emit panic with code 0x12 when b is zero
c = a / b;
}
}

測試和 Log 如下:

import "forge-std/Test.sol";
import {ErrorEmittor} from "../src/ErrorEmittor.sol";

contract ErrorCatcher {
ErrorEmittor private _instance;

function setUp() external {
_instance = new ErrorEmittor();
}

function test_catch_panic() external view {
// call success
try _instance.emitPanic(10, 2) returns (uint256 c) {
console.log("get the result: %d", c);
} catch Panic(uint256) {
console.log("unreachable log");
}
// catch panic
try _instance.emitPanic(10, 0) returns (uint256) {
console.log("unreachable log");
} catch Panic(uint256 panicCode) {
console.log("get the panicCode: %x", panicCode);
}
}
}
[PASS] test_catch_panic() (gas: 10256)
Logs:
get the result: 5
get the panicCode: 0x12

Traces:
[10256] ErrorCatcher::test_catch_panic()
├─ [342] ErrorEmittor::emitPanic(10, 2) [staticcall]
│ └─ ← [Return] 5
├─ [0] console::log("get the result: %d", 5) [staticcall]
│ └─ ← [Stop]
├─ [286] ErrorEmittor::emitPanic(10, 0) [staticcall]
│ └─ ← [Revert] panic: division or modulo by zero (0x12)
├─ [0] console::log("get the panicCode: %x", 18) [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]

Try-catch

總結一下會被拋出的錯誤有:Error(string), Panic(uint256), error CustomError()。再來回來看 try-catch 的行為。首先,try 這個關鍵字後面只能接「external function 的呼叫」或是「透過 new 關鍵字去建立一個新的合約」:

// external function call
address private _addr;
try IERC20(_addr).transfer(from, to, amount) returns (bool) {
...
}

// new contract
try new ERC20("sample", "SMT") returns (ERC20 erc20) {
...
}

呼叫後的回傳的資料會透過 returncodecopy opcode 存入 memory region。接著 catch 關鍵字後面會附上錯誤資訊的型別並將存入 memory region 的資料做 abi decode,最後由後面的邏輯處理。以下為例,一個用來捕捉 Error(string),另一個用來捕捉 Panic(uint256)

address private _addr;
try IERC20(_addr).transfer(from, to, amount) returns (bool) {
...
} catch Error(string memory reason) {
// handle reason string
} catch Panic(uint errorCode) {
// handle Panic
}

catch 沒有支援捕捉 custom error

// ❌
catch CustomError() {
...
}

如果拋出的錯誤不是 Error(string) 或是 Panic(uint256),可以寫一個 default catch 做捕捉。default catch 有兩種寫法:catch (bytes memory data) {…}catch {…}。這兩種寫法的差異只在於需不需要錯誤的資訊而已

被遺忘的 Custom Error 則可以在 catch (bytes memory){} 中以 bytes memory 型別被捕捉,開發者可以自行做 abi decode 處理,以下為 try catch 的整體結構:

address private _addr;
try IERC20(_addr).transfer(from, to, amount) returns (bool) {
...
} catch Error(string memory reason) {
...
} catch Panic(uint errorCode) {
...
} catch (bytes memory data) {
...
}

// or
address private _addr;
try IERC20(_addr).transfer(from, to, amount) returns (bool) {
...
} catch Error(string memory reason) {
...
} catch Panic(uint errorCode) {
...
} catch {
...
}

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 發生錯誤:

// memory layout, total len = 0x04 + 0x20 + 0x20 + 0x03

[0x40]: 0x8c379a0 | error selector of `Error(string)`, len = 0x04
[0x60]: 0x20 | string offset, len = 0x20
[0x80]: 0x03 | string length, len = 0x20
[0xa0]: 'cat' | string data, len = 0x03
contract Emit {
function revv(bool isCorrectLen) external {
uint256 len = isCorrectLen ? 0x47 : 0x44;

assembly {
let ptr := 0x40
mstore(ptr, 0x08c379a0) // error selector of `Error(string)`
mstore(add(ptr, 0x20), 0x20) // string offset
mstore(add(ptr, 0x43), 0x636174) // 'cat'
mstore(add(ptr, 0x40), 0x3) // string length = 3
revert(add(ptr, 0x1c), len)
}
}
}

測試和 log 如下,因為 decode 成 Error(string) 中出現錯誤,所以只能以型別 bytes memory 被捕捉:

contract Catcherrr {
Emit private immutable emitter;

constructor() {
emitter = new Emit();
}

function test_cat() external {
try emitter.revv(false) {
console.log("call success");
} catch Error(string memory reason) {
console.logString(reason);
} catch (bytes memory err) {
console.logBytes(err);
}
console.log("error had been caught");
}
}
[PASS] test_cat() (gas: 7805)
Logs:
0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003
error had been catched

Traces:
[7805] Catcherrr::test_cat()
├─ [357] Emit::revv(false)
│ └─ ← [Revert]
├─ [0] console::logBytes(0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("error had been catched") [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]

reason 2: 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範例做修改):

contract PlasticBombs {
function bomb() external {
assembly {
// revert with hardcode length
revert(0x00, 0xffff)
}
}
}

contract Trigger {
PlasticBombs private _bombs = new PlasticBombs();

event LogCase1(bytes);
event LogCase2();

function tryCase1() external {
try _bombs.bomb() {
// unreachable
} catch (bytes memory data) {
// default branch and care about error information
emit LogCase1(data);
}
}

function tryCase2() external {
try _bombs.bomb() {
// unreachable
} catch {
// default branch without error information
emit LogCase2();
}
}
}

測試和 Log 如下:

contract TestAnother is Test {
Trigger private _instance = new Trigger();

function test_case1() external {
// hardcode the gas
(bool succ,) = address(_instance).call{gas: 100_000}(abi.encodeCall(Trigger.tryCase1, ()));
assertTrue(succ);
}

function test_case2() external {
// hardcode the gas
(bool succ,) = address(_instance).call{gas: 100_000}(abi.encodeCall(Trigger.tryCase2, ()));
assertTrue(succ);
}
}
[FAIL. Reason: assertion failed] test_case1() (gas: 108189)
Traces:
[108189] TestAnother::test_case1()
├─ [100000] Trigger::tryCase1()
│ ├─ [14446] PlasticBombs::bomb()
│ │ └─ ← [Revert]
│ └─ ← [OutOfGas] EvmError: OutOfGas
├─ [0] VM::assertTrue(false) [staticcall]
│ └─ ← [Revert] assertion failed
└─ ← [Revert] assertion failed

[PASS] test_case2() (gas: 28600)
Traces:
[28600] TestAnother::test_case2()
├─ [20341] Trigger::tryCase2()
│ ├─ [14446] PlasticBombs::bomb()
│ │ └─ ← [Revert]
│ ├─ emit LogCase2()
│ └─ ← [Stop]
├─ [0] VM::assertTrue(true) [staticcall]
│ └─ ← [Return]
└─ ← [Stop]

test_case1 的 log 可以看出,external call 的執行是成功的,但是 revert 回傳的資料長度觸發 memory expansion 將所有的 gas 都消耗掉了。而從 test_case2 可以看出,default branch catch {} 並不會將 revert 回傳的資料存入 memory region 也不會對其做 abi decode。

Conclusion

回顧一下 try-catch 做了什麼事:

  1. 呼叫外部合約
  2. 如果需要處理錯誤資訊,則將資料存入 memory region (可能是 success 或是 revert)
  3. 將資料 decode 之後由 try block 或是 catch block 處理

現在越來越多的合約都轉向使用 Custom Error 來節省 gas 開銷,只能針對 external function 但又沒辦法按照預期捕捉 Custom Error 的 try-catch 用起來就不是那麼方便。

如果只是單純不要讓交易 revert,這樣寫 try Call() catch {} 是可行的,不需要處理 revert 回傳的資料,就不會有 returndatacopy 造成 return bomb。但是如果要處理 revert 回傳的資料,請用在可信任的合約或是在 protocol 內部做錯誤處理

--

--