通常のトランザクションがコントラクト A に入り、A が DELEGATECALL で実行を実装コントラクト B に委ねます。実行されるバイトコードは B のものですが、address(this) が返すのは A のアドレスであり、msg.sender は最初にトランザクションを発起したアカウントで、B 内の storage への書き込みは A のスロットに落ちます。DELEGATECALL を CALL に置き換えると、これらの値はすべて変わります。STATICCALL に置き換えると、あらゆる書き込み操作がそのまま例外を投げます。
4 つの呼び出し命令はバイトコードのレベルでは番号が 1 つ違うだけですが、そのセマンティクスの差はプロキシのアップグレード、ライブラリ呼び出し、読み取り専用クエリ、重入対策にまで及びます。本稿では、実行コンテキスト、ストレージの帰属、メッセージフィールド、ガスにおける挙動を 1 つずつ対照し、DELEGATECALL がなぜプロキシコントラクトの基礎になったのか、STATICCALL がなぜ専用の EIP を必要としたのか、CALLCODE がなぜ Frontier の一員から非推奨の遺物になったのかを説明します。
4 つの命令の番号とスタックパラメータ
まず外見を揃えておきます。CALL は 0xf1、CALLCODE は 0xf2、DELEGATECALL は 0xf4、STATICCALL は 0xfa です。4 つはいずれも失敗時にスタックへ 0 を、成功時に 1 をプッシュし、メモリのオフセットと長さで入出力の区間を記述し、1024 段の呼び出し深度の制限を受け、ガス不足時には黙って切り詰めるのではなく失敗します。
スタックパラメータの数がこれらを 2 つの組に分けます。CALL と CALLCODE はそれぞれ 7 つのオペランドを取ります。gas、対象アドレス、value、入力オフセット、入力長、出力オフセット、出力長です。DELEGATECALL と STATICCALL はそれぞれ 6 つを取り、value の項目がありません。DELEGATECALL は親スコープの msg.value をそのまま用い、STATICCALL はそれを 0 に固定します。パラメータ表の違い自体がセマンティクスを理解する入口です。value を自分で指定できる 2 つの命令は、1 つが実際に送金し(CALL)、1 つが送金しません(CALLCODE)。value を指定できない 2 つの命令は、1 つが継承し(DELEGATECALL)、1 つがゼロにします(STATICCALL)。
戻りデータの扱いも対照する価値があります。4 つはいずれも子呼び出しの戻りデータを呼び出し側が指定したメモリ区間へ書き込みますが、書き込む長さは呼び出し側が与える out_size に制限されます。Byzantium アップグレードが RETURNDATASIZE と RETURNDATACOPY を導入してから(EIP-211)、呼び出し側は戻りデータの大きさを事前に推測する必要がなくなり、まず長さを読んでから必要に応じてコピーできます。そのためプロキシや汎用フォワーダーコントラクトは、十分に大きな出力領域を確保しておく必要がありません。
1 つずつ対照する:コードはどこから来て、状態はどこに書かれ、メッセージフィールドは誰のものか
CALL は新しい実行コンテキストを作ります。コードは対象アドレスから取り、storage も対象アドレスに属し、address(this) は対象コントラクト、msg.sender は呼び出しを発起した現在のコントラクトです。value で指定した ether は現在のコントラクトから対象アドレスへ実際に移されるため、現在のコントラクトの残高が足りなければ呼び出しは失敗します。
CALLCODE も対象アドレスのコードを取って実行しますが、実行は現在のアカウントのコンテキストで行われます。storage は現在のコントラクトのもので、address(this) は現在のコントラクトのアドレスです。CALL より直感に反する点がもう 1 つあります。value は呼び出し側が指定する任意の値でよく、msg.value はそれによって親スコープとは異なる数値に書き換えられます。しかもいわゆる価値の移転は現在のアカウントが自分自身へ移すもので、実際の残高変化は生じません。価値の検査自体は依然として必要です。イエローペーパーの 0xf2 の定義は、呼び出しの前提として value が現在のアカウントの残高を超えないことを要求しており、そうでなければ呼び出されるコードへは入らず、スタックには 0 が得られます。CALLCODE の msg.sender はそれを実行している現在のコントラクトで、この点は CALL と同じであり、DELEGATECALL との最も重要な相違でもあります。
DELEGATECALL も同様に現在のアカウントのコンテキストで対象コードを実行し、storage と address(this) はいずれも現在のコントラクトを指しますが、親スコープの msg.sender と msg.value をそのまま子スコープへ持ち込みます。EIP-7 の表現では、送信者と価値は親スコープから子スコープへ伝播し、子コード内の CALLER と VALUE の挙動は親環境と完全に一致します。これには直接の利点があります。実装コントラクトは msg.sender と msg.value を自由に参照でき、プロキシ経由の呼び出しに合わせてわざわざ再コンパイルする必要がありません。EIP-7 の動機の節ではさらに 2 つの用法が挙げられています。実装コードを複数に分割して段階的に実行し、当時およそ 300 万ガスだった呼び出しの上限を回避すること。そして可変のアドレスにコードの出所を保持し、呼び出しをそのアドレスへ透過させることです。
STATICCALL が作る実行コンテキストは CALL と似ています。コードと storage はともに対象アドレスに属し、address(this) は対象コントラクト、msg.sender は呼び出し側、msg.value は 0 です。違いは、子スコープに静的マークを付けて、実行中のあらゆる状態変更を禁止する点です。EIP-214 が挙げる禁止項目には CREATE、CREATE2、LOG0 から LOG4、SSTORE、SELFDESTRUCT、および非ゼロの value を伴う CALL が含まれます。これらの操作に遭遇すると、変更を実行するのではなくそのまま例外を投げます。仕様は 1 つの例外を残しています。CALLCODE は非ゼロの value を伴っても状態変更とは見なされません。CALLCODE のセマンティクスではその価値が現在のアカウントから出ていないからです。
対照表
下表のメッセージフィールドは、呼び出されたコードの内部で観測される値を指し、address(this) は対象コードの実行時に ADDRESS がスタックへプッシュする結果を指します。
| 命令 | 番号 | スタックパラメータ | コードの出所 | storage の帰属 | address(this) | msg.sender | msg.value | 状態の書き込み |
|---|---|---|---|---|---|---|---|---|
| CALL | 0xf1 | 7(value を含む) | 対象アドレス | 対象アドレス | 対象アドレス | 現在のコントラクト | 指定値、実際に送金 | 可 |
| CALLCODE | 0xf2 | 7(value を含む) | 対象アドレス | 現在のコントラクト | 現在のコントラクト | 現在のコントラクト | 指定値、残高検査を通過する必要があるが実際には送金しない | 可 |
| DELEGATECALL | 0xf4 | 6 | 対象アドレス | 現在のコントラクト | 現在のコントラクト | 親スコープを継承 | 親スコープを継承 | 可 |
| STATICCALL | 0xfa | 6 | 対象アドレス | 対象アドレス | 対象アドレス | 現在のコントラクト | 0 に固定 | 不可 |
ガス:基本料金、アクセスコスト、63/64 の保持ルール
4 つの命令のガスはいくつかの部分の積み重ねです。基本コスト、対象アドレスのアクセスコスト、メモリ拡張コスト、そして実際に子呼び出しへ引き渡す枠です。子呼び出しへ渡した分は使い切られなければ返却されるため、支出というより枠に近いものです。
対象アドレスのアクセスコストは Berlin アップグレード(EIP-2929)以降、コールド/ウォームの課金に変わりました。同一トランザクション内で最初のアクセスはコールドアクセスで 2600 ガス、2 回目以降のアクセスはウォームアクセスで 100 ガスです。それ以前に EIP-150 は CALL、CALLCODE、DELEGATECALL の基本コストを一律 700 ガスへ引き上げていましたが、Berlin 以降は呼び出し系もコールド/ウォーム課金に変わり、2600 と 100 が固定の 700 に取って代わりました。課金は利用可能なガスの計算より前に行われます。アドレスの集合は同一トランザクション内で共有され、ある階層の実行がロールバックすれば、その階層で新たに加わったアクセス記録もともにロールバックします。このコストを節約したい呼び出し側は、トランザクションに EIP-2930 のアクセスリスト(access list)を添付し、触れる予定のアドレスとスロットを事前に宣言できます。代償は項目ごとの固定料金の支払いです。
価値の移転とアカウントの作成には追加料金がかかります。CALL が非ゼロの value を伴う場合は 9000 ガスが加算されます。受け取り側が dead アカウント(存在しないか空)で、状態木に新規作成する必要がある場合は、さらに 25000 が加算されます。CALLCODE も非ゼロの value を伴う場合は同様に 9000 が加算されますが、その価値は現在のアカウントへ移されるだけで、アカウントの新規作成は伴いません。DELEGATECALL と STATICCALL には value パラメータがなく、この 2 項目もありません。
2300 ガスの stipend はもう 1 つ間違えやすい点です。非ゼロの value を伴う CALL と CALLCODE では、子呼び出しが追加で 2300 ガスを受け取ります。イエローペーパーの料率表はこの枠を「G_callvalue から差し引く」と記録しています。つまりそれは 9000 の価値移転の追加料金に含まれており、追加料金とは別に補助があるわけではありません。これは transfer() と send() のガス上限の由来でもあります。この 2 つのメソッドは 2300 ガスしか転送せず、受け取り側が storage を書き込むかログを出す必要があると即座に失敗します。Solidity のドキュメントは send() と transfer() を非推奨としており、削除も計画しています。CALL に切り替えて戻り値を自分で確認するよう推奨しています。
EIP-150 はさらに「1/64 の保持」ルールを導入しました。転送を要求するガスが呼び出し側の残り利用可能ガスの 63/64 を超える場合、実際には 63/64 しか転送せず、親スコープは常に失敗の戻りを処理するためのガスを確保します。このルールは、かつて呼び出し深度に基づいていた攻撃面をガスに基づく制限へ置き換えました。また、子呼び出しがガスを使い切っても、親呼び出しは失敗フラグを受け取って実行を続けられ、トランザクション全体が一緒に使い切ってしまうわけではない理由も説明します。
DELEGATECALL はプロキシコントラクトの基礎であり、代償は storage layout の制約
DELEGATECALL があって初めてプロキシパターンが成立します。プロキシコントラクトが状態と資産を保持し、呼び出しを実装コントラクトへ転送し、実装はプロキシのコンテキストでプロキシの storage を読み書きし、アップグレード時には実装アドレスだけを変え、状態はそのまま保持されます。これが EVM におけるアップグレード可能なコントラクトの標準的な手法でもあります。最小のフォワーダー関数の書き方はおおよそ次のとおりで、実装アドレスを引数として受け取り、実装コントラクトのレイアウトと衝突しないよう、あえて storage を占有しません。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Forwarder {
function _delegate(address impl) external payable {
assembly {
calldatacopy(0, 0, calldatasize())
// 呼び出し側(プロキシ)のコンテキストで impl のコードを実行し、storage と msg.sender はプロキシ側に残る
let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch ok
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
代償は、ストレージレイアウト(storage layout)を揃えなければならないことです。DELEGATECALL は名前を付け替えることもスロットを移行することもなく、コードはスロット番号かコンパイラが割り当てたオフセットに従って読み書きします。プロキシ自身が変数を宣言し、実装コントラクトがたまたま同じスロットに変数を宣言していると、両者は互いを上書きします。そこで EIP-1967 は、実装アドレスを bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1) で算出したスロット、すなわち 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc に書き込みます。この位置は確率的にコンパイラが業務変数へ割り当てることはありません。同様に、実装コントラクトが状態変数を追加できるのは末尾だけであり、変数を削除したり並べ替えたりすると、アップグレード後の読み書きが全体としてずれます。
コンストラクタ(constructor)にもプロキシパターン特有の制約があります。実装コントラクトをデプロイするとき、コンストラクタは自分自身のコンテキストで実行され、実装コントラクト自身の storage に書き込むだけで、プロキシの状態には書き込みません。したがってプロキシの初期化は明示的な初期化関数で行わなければならず、しかも 1 度だけ実行される印が必要です。そうでなければ誰でも再初期化して制御権を奪えます。
もう 1 種類のリスクは呼び出されるコード自体から来ます。DELEGATECALL は対象コードにプロキシの全権限を与えます。任意のスロットへ書き込み、残高を移し、他のコントラクトを呼び出し、さらには selfdestruct によってプロキシの実行可能性に影響を与えられます。実装アドレスが任意に変更できるようになっていたり、監査を受けていないライブラリを持ち込んでいたりすると、プロキシの制御権を丸ごと引き渡すことに等しくなります。Solidity のライブラリ(library)の公開関数はまさに DELEGATECALL で呼び出されるため、ライブラリのコードは自分の状態変数を宣言できません。宣言すれば呼び出し側のレイアウトと衝突するからです。ライブラリの非 view・非 pure 関数は DELEGATECALL 経由でしか呼べないよう設計されており、その実行時コードには呼び出し保護が付いています。ライブラリのアドレスへ直接 CALL を発行すると実行時に revert します(view や pure の関数を呼ぶときはこの保護を受けません)。
STATICCALL は状態変更を例外にする
STATICCALL は EIP-214 で導入され、Byzantium アップグレードで有効化されました。EIP-214 はオペコードとセマンティクスを示すだけでフォーク名を書いておらず、フォークの帰属は EIP-214 を Byzantium の包含リストに加えた EIP-609 に由来します。動機はこうです。通常の CALL の後では、呼び出し側は呼び出されたコントラクトの状態が変化していないと仮定できず、重入系の問題を局所的に推論するのが難しくなります。EIP-214 が示す目標は、静的呼び出しの前後ですべてのアカウントの状態が一致することであり、この呼び出しは出力を返すだけで副作用を生まない純粋関数と見なせます。
実装方法は EVM に STATIC フラグを追加することです。このフラグは既定で false で、子呼び出しへ入るときは通常そのまま複製され、STATICCALL だけがそれを true にし、戻ると復元します。禁止項目は前節で挙げたとおりで、要点はこのフラグが呼び出しチェーンに沿って伝播することです。静的呼び出しの中でさらに CALL を発行すると、子呼び出しも同じく静的モードになり、階層を 1 つ余分に挟んでも制限を回避できません。
工学的には 2 種類の利点があります。読み取り専用のクエリを安全に組み合わせられ、価格ビューや複数コントラクトの集約呼び出しは、呼び出し先が状態を書き換える心配がありません。重入対策はより早い段階で塞げます。外部の読み取り専用呼び出しを静的モードに入れれば、重入による書き込みは EVM 層で直接失敗します。Solidity 0.5.0 以降、ライブラリ以外の view と pure 関数の呼び出しは既定で STATICCALL へコンパイルされ、コンパイル期の制約に加えて実行期の受け皿が 1 層増えます。ライブラリの view 関数は例外で、依然として DELEGATECALL を使います。EVM には静的セマンティクスと委譲セマンティクスを同時に備えた命令がないため、ライブラリの読み取り専用関数には実行期の状態保護がありません。この点は監査の際に個別に確認する必要があります。
境界も明確にしておきます。STATICCALL は状態の変更だけを禁止し、読み取りも禁止せず、コールバックも禁止しません。重入の書き込み経路を遮断するだけで、重入問題が全体として消えるわけではありません。view / pure のコンパイル期チェックの代わりにもならず、両者が覆う失敗モードは異なります。静的呼び出しも同じくガスを消費し、対象コントラクトが故意に revert して呼び出し側の手数料を攻撃コストに変えることもできます。静的モードが保証するのは状態が変わらないことだけで、呼び出しの成功や結果の信頼性を保証するものではありません。
CALLCODE の歴史的な位置づけと退場
CALLCODE は Frontier の時点で存在していた命令で、EIP-7 の動機の節はそれをそのまま DELEGATECALL の対照物として扱っています。設計目標は DELEGATECALL に近く、どちらも「他人のコードを借りて現在のアカウント上で実行する」ものですが、メッセージフィールドの扱いが異なります。msg.sender は現在のコントラクトに書き換えられ、msg.value は任意に指定できます。元の送信者と価値を透過させる必要がある場合には役に立たず、EIP-7 が送信者と価値を伝播させる方式でこの点を補いました。
価値セマンティクスの混乱がその退場を加速しました。非ゼロの value を伴う CALLCODE は現在のアカウントの残高が足りていることを要求しながら、価値は自分自身のアドレスへ移し、子呼び出しでは書き換えられた msg.value が読まれ、帳簿上は何の移転も起こらない——こうした挙動は直感で推論するのが困難です。実際の利用状況については、EIP-2488 の著者は CALLCODE が本当に使われたことは一度もないと見ており、プロキシパターンの大規模な普及はさらに遅い時期でした(DELEGATECALL は Homestead で導入され、EIP-7 が示すメインネットの有効化ブロックは 1,150,000)。この 2 点は公開されたタイムラインと著者の発言に基づく判断であり、定量化できる呼び出し統計を欠いています。
退場は 2 段階に分かれます。Solidity は 0.5.0 以降 callcode を許可しなくなり、インラインアセンブリだけが依然として 0xf2 命令を発行できます。プロトコル層はこのオペコードを実際には削除しておらず、EIP-2488 は CALLCODE をあるブロック高以降つねに失敗を返すようにすることを提案しています。理由は、オペコードを直接削除するとそれに遭遇したコントラクトが異常終了してしまうのに対し、失敗を返せばコントラクトが感知して復帰する機会が得られるからです。この提案は今も停滞(Stagnant)状態のままです。結論として、CALLCODE は言語のレベルではすでに退場しましたが、バイトコードのレベルでは実装側が正しく扱う必要のある遺留セマンティクスのままであり、両者を混同してはいけません。
呼び出しコンテキストが状態の書き込み先スロットを決める
4 つの命令は最終的に同じ問いに答えます。このコードは誰のコンテキストで動き、誰のストレージへ書き込むのか。CALL と STATICCALL は状態を呼び出されたコントラクトへ書き込み、DELEGATECALL と CALLCODE は呼び出しを発起したコントラクトへ書き込みます。単一スレッドの実行にとって、この違いは正しさに影響するだけです。並列実行にとっては、これがコンフリクト検出の入力を決めます。スケジューラが 2 つのトランザクションが互いに干渉するかを判断するには、それらが最終的にどの「アドレスとストレージスロットの組」へ書き込むのかを知らなければならず、呼び出しコンテキストはここでのアドレスがプロキシを取るか実装を取るかを決める鍵です。プロキシパターンでは 1 回の転送が書き込むのはプロキシのスロットであり、実装コントラクトのスロットではありません。コンフリクト検出が同一のストレージスロットをめぐってどう展開するかは、ストレージレイアウトと読み書き集合を扱う後続の議論に譲ります。
出典
- EIP-7「DELEGATECALL」、オペコード
0xf4、送信者と価値の伝播、Homestead の有効化ブロック 1,150,000 と歴史的な動機:https://eips.ethereum.org/EIPS/eip-7 - EIP-214「New opcode STATICCALL」、静的フラグ、禁止操作の一覧、CALLCODE の例外:https://eips.ethereum.org/EIPS/eip-214
- EIP-609「Hardfork Meta: Byzantium」、EIP-211 と EIP-214 を Byzantium の包含リストに加えるメタ提案:https://eips.ethereum.org/EIPS/eip-609
- EIP-211「New opcodes: RETURNDATASIZE and RETURNDATACOPY」、動的な戻りデータと
BYZANTIUM_FORK_BLKNUM:https://eips.ethereum.org/EIPS/eip-211 - EIP-150「Gas cost changes for IO-heavy operations」、呼び出しの基本料金 700 と 63/64 の保持ルール:https://eips.ethereum.org/EIPS/eip-150
- EIP-2929「Gas cost increases for state access opcodes」、コールドアクセス 2600、ウォームアクセス 100、アクセス集合:https://eips.ethereum.org/EIPS/eip-2929
- ERC-1967(旧 EIP-1967)「Proxy Storage Slots」、実装アドレスのスロット
keccak256("eip1967.proxy.implementation") - 1:https://eips.ethereum.org/EIPS/eip-1967 - EIP-2488「Deprecate the CALLCODE opcode」、つねに失敗を返す動機、状態は停滞(Stagnant)で未活性化:https://eips.ethereum.org/EIPS/eip-2488
- Ethereum Yellow Paper、付録 H の CALL / CALLCODE / DELEGATECALL / STATICCALL の定義とオペコード表(CALLCODE の
value ≤ 残高の前提を含む)、付録 G の料率表:https://ethereum.github.io/yellowpaper/paper.pdf - Solidity 0.5.0 の破壊的変更(
callcodeが許可されなくなった)、コントラクトのドキュメントにおけるview/pureの STATICCALL 使用、ライブラリのview関数の DELEGATECALL 使用、ライブラリの呼び出し保護、send()/transfer()が非推奨になった説明:https://docs.soliditylang.org/en/v0.5.0/050-breaking-changes.html 、https://docs.soliditylang.org/en/latest/contracts.html - ethereum.org の EVM オペコードリファレンスと wolflo/evm-opcodes の動的ガス表、価値移転 9000、新規アカウント作成 25000、2300 の stipend:https://ethereum.org/en/developers/docs/evm/opcodes/ 、https://github.com/wolflo/evm-opcodes/blob/main/gas.md