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 已經整理成一個表格如下:

codedescription
0x00Used for generic compiler inserted panics
0x01If you call assert with an argument that evaluates to false
0x11If an arithmetic operation results in underflow or overflow outside of an unchecked { ... } block
0x12If you divide or modulo by zero (e.g. 5 / 0 or 23 % 0)
0x21If you convert a value that is too big or negative into an enum type
0x22If you access a storage byte array that is incorrectly encoded
0x31If you call .pop() on an empty array
0x32If 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)
0x41If you allocate too much memory or create an array that is too large
0x51If you call a zero-initialized variable of internal function type

example

以下為觸發 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

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 內部做錯誤處理

Reference