はじめに
『DApps開発入門』という本や色々記事を書いているかるでねです。
今回は、スマートコントラクトで発生するエラーを「WrappedError
」という形で標準化し、どのコントラクトや関数で失敗したのかをわかりやすく伝える仕組みを提案しているERC7751についてまとめていきます!
以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。
他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。
概要
ERC7751では、イーサリアムのスマートコントラクトにおいて「バブルアップされたリバート(bubbled up reverts)」を扱うための標準を提案しています。
バブルアップとは、コントラクト内で別のコントラクトの関数を呼び出し、その呼び出し先の関数でエラーが発生した結果が呼び出し元のコントラクトに戻ってくることです。
通常、スマートコントラクトが別のコントラクトを呼び出し、その呼び出し先がリバート(失敗)を返した場合、そのエラーはそのまま呼び出し元のコントラクトに返されます。
しかし、このままではエラーがどこで発生したのか、またどんな理由で起こったのかが分かりづらいという問題があります。
そこで、ERC7751では WrappedError
という専用のカスタムエラーを導入します。
このエラーは、呼び出し先からバブルアップしたリバートをラップすることで、追加のコンテキスト情報とともに上位に渡すことができます。
これにより、EtherscanやTenderlyといった解析ツールでも、一貫性のある形でエラーを解釈・表示できるようになります。
動機
現在の仕組みでは、スマートコントラクトが他のコントラクトを呼び出し、その呼び出し先がリバートした場合、返却されるリバート理由が「そのまま」呼び出し元のコントラクトに渡されます。
この挙動では以下のような課題があります。
-
コンテキストが不足している
どのコントラクト呼び出しで失敗したのか、またその原因がどのレイヤーにあるのかを特定するのが難しい。 -
デバッグがしづらい
単純に「失敗しました」という情報だけでは、開発者がエラーの発生源を追跡するのに時間がかかる。 -
ツール側での処理が不便
例えばEtherscanやTenderlyといったインフラ系ツールでは、エラーがどの呼び出し階層から来たのかが分からず、スタックトレースの表示が不完全になりがち。
こうした問題を解決するために、ERC7751では「標準化されたカスタムエラー」を利用します。
WrappedError
でリバートをラップすることで、以下のようなメリットがあります。
- 開発者にとって、どの呼び出しで問題が起きたかが明確になり、デバッグ効率が向上する。
- インフラ提供者にとって、一貫したフォーマットでリバートを扱えるため、ツール上でより正確なエラーメッセージやスタックトレースを表示できる。
結果として、エコシステム全体で「リバート理由の明確さ」と「使いやすさ」が大幅に改善されることが期待されます。
仕様
WrappedError
エラーの定義
リバート(処理失敗)をラップするために、コントラクトは以下のシグネチャを持つエラーでリバートする必要があります。
error WrappedError(address target, bytes4 selector, bytes reason, bytes details);
このエラーは、シグネチャ(識別子)として 0x90bfb865
を持ちます。
この値は、関数やエラーを一意に識別するための「selector
」と呼ばれるもので、自動的に計算されるハッシュの一部です。
パラメータ
WrappedError
は以下の4つのパラメータを持ちます。
パラメータ | 説明 |
---|---|
target |
リバートを引き起こした呼び出し先コントラクトのアドレス。 |
selector |
リバートを起こした関数のセレクタ。もしETHの送金(データを伴わない単純なtransfer)の場合は bytes4(0) を入れる必要がある。 |
reason |
リバート理由の生のバイト列(元のエラー内容そのもの)。 |
details |
リバートに関する追加コンテキスト。不要な場合は空にできる。必要がある場合は、ABIエンコードされたカスタムエラー を格納する。 |
details
フィールド
details
フィールドはオプションですが、ERC7751において重要な役割を持ちます。
- 追加情報が不要な場合
空のbytes
として扱う。 - 追加情報が必要な場合
呼び出し元コントラクトが定義している「カスタムエラー」をABIエンコード
して格納する。
これにより、単なるエラー理由だけでなく、より詳細な情報(例えば「どのユーザーが」、「どの金額を」、「どの操作で」失敗したのかなど)を含めることが可能になります。
補足
WrappedError
で呼び出し先のコントラクトや関数、リバートの生バイト列、さらに追加コンテキストを含めることで、実行失敗に関する情報をより詳細に提供できます。
これにより、単に「失敗しました」という情報だけではなく、どのコントラクトのどの関数で何が起きたのかが明確になります。
さらに、この仕組みを標準化することで「ネストされたバブルアップリバート」も扱えるようになります。
つまり、複数のコントラクトが呼び出し合ってそれぞれでリバートが発生した場合でも、その一連のリバートを再帰的にたどることが可能になります。
EtherscanやFoundryのようなツールは、この仕組みを活用してリバートを解析し、人間が読みやすい形に変換できます。
結果として、スマートコントラクト間のやりとりがより理解しやすくなり、デバッグ効率やエラーハンドリングの実践が向上します。
互換性
ERC7751は既存のスマートコントラクトに対して互換性の問題を引き起こしません。
すでにデプロイされているコントラクトを壊すことなく、必要な場合にのみ徐々にこの標準を取り入れることができます。
テストケース
以下は、WrappedError
を使ってリバートをラップし、入れ子になったリバートをテストする例です。
このテストでは、Token
→ Vault
→ Router
とコントラクトを呼び出し、ネストされたリバートが正しく伝播するかを確認しています。
// SPDX-License-Identifier: CC0-1.0
pragma solidity 0.8.26;
contract Token {
mapping(address => uint256) public balanceOf;
event Transfer(address indexed sender, address indexed recipient, uint amount);
function transfer(address to, uint256 amount) external returns (bool) {
require(balanceOf[msg.sender] >= amount, "insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
}
contract Vault {
Token token;
error WrappedError(address target, bytes4 selector, bytes reason, bytes details);
error ERC20TransferFailed(address recipient);
constructor(Token token_) {
token = token_;
}
function withdraw(address to, uint256 amount) external {
try token.transfer(to, amount) {} catch (bytes memory error) {
revert WrappedError(
address(token),
token.transfer.selector,
error,
abi.encodeWithSelector(ERC20TransferFailed.selector, to)
);
}
}
}
contract Router {
Vault vault;
error WrappedError(address target, bytes4 selector, bytes reason, bytes details);
constructor(Vault vault_) {
vault = vault_;
}
function withdraw(uint256 amount) external {
try vault.withdraw(msg.sender, amount) {} catch (bytes memory error) {
revert WrappedError(address(vault), vault.withdraw.selector, error, "");
}
}
}
contract Test {
function test_BubbledNestedReverts(uint256 amount) external {
Token token = new Token();
Vault vault = new Vault(token);
Router router = new Router(vault);
try router.withdraw(amount) {} catch (bytes memory thrownError) {
bytes memory expectedError = abi.encodeWithSelector(
Router.WrappedError.selector,
address(vault),
vault.withdraw.selector,
abi.encodeWithSelector(
Vault.WrappedError.selector,
address(token),
token.transfer.selector,
abi.encodeWithSignature("Error(string)", "insufficient balance"),
abi.encodeWithSelector(Vault.ERC20TransferFailed.selector, address(this))
),
""
);
assert(keccak256(thrownError) == keccak256(expectedError));
}
}
}
このテストにより、複数のコントラクトにまたがるリバートが WrappedError
を通じて正しく伝播・識別できることが確認できます。
参考実装
実際の実装例として、以下のように呼び出し先のリバートをキャッチして WrappedError
でそのエラーを返しています。
contract Foo {
error WrappedError(address target, bytes4 selector, bytes reason, bytes details);
error MyCustomError(uint256 x);
function foo(address to, bytes memory data) external {
(bool success, bytes memory returnData) = to.call(data);
if (!success) {
revert WrappedError(
to,
bytes4(data),
returnData,
abi.encodeWithSelector(MyCustomError.selector, 42)
);
}
}
}
このコードでは、呼び出しに失敗した場合に WrappedError
を返しつつ、追加のカスタムエラー(MyCustomError
)をエンコードして details
に渡しています。
セキュリティ
この仕組みには、以下のようなセキュリティ上の注意点があります。
- コントラクトが意図的にリバートを落としたり、バブルアップを途中で遮断したりする可能性がある。
- また、
WrappedError
の内容を偽装することも可能であり、必ずしもすべての情報が正しいとは限らない。
つまり、WrappedError
はあくまで「デバッグやツール支援に役立つ情報源」であり、完全に信頼できるものとして依存してはいけません。
引用
Daniel Gretzke (@gretzke), Sara Reynolds (@snreynolds), Alice Henshaw (@hensha256), Marko Veniger marko.veniger@tenderly.co, Hadrien Croubois (@Amxx), "ERC-7751: Wrapping of bubbled up reverts," Ethereum Improvement Proposals, no. 7751, August 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7751.
最後に
今回は「スマートコントラクトで発生するエラーを「WrappedError
」という形で標準化し、どのコントラクトや関数で失敗したのかをわかりやすく伝える仕組みを提案しているERC7751」についてまとめてきました!
いかがだったでしょうか?
質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!
他の媒体でも情報発信しているのでぜひ他も見ていってください!