Bitrootブログ
公式サイトへ戻る ↗
© 2026 Bitroot · 本サイトの内容は一般情報であり、金融・投資・法律・税務上の助言ではありません。
編集基準公式サイトへ戻る
← すべての記事
EVM 基礎·2026/10/02·約 6 分

ABI 精読:セレクタ、静的パラメータ、動的型

生の calldata から ABI を分解します。4 バイトのセレクタは正規署名を切り詰めたもので、静的型はそのまま 1 ワードを占め、動的型はオフセットと長さによる二段階のアドレッシングで辿ります。イベントは indexed パラメータを topics に書き込むことで検索性を得ます。ABI は自己記述的ではないため、デコードとアップグレードはいずれも制約を受けます。

ERC-20 の送金の calldata は通常 0xa9059cbb で始まり、その後に 32 バイトのワードが 2 つ続きます。1 つ目は受け取りアドレス、2 つ目は金額です。このバイト列が transfer(address,uint256) に対応することを示すフィールドはオンチェーンには存在せず、EVM(Ethereum Virtual Machine、イーサリアム仮想マシン)も関数名を理解しません。EVM はデプロイ時にバイトコードへコンパイルされたディスパッチロジックに従い、calldata の先頭 4 バイトを比較して、対応するコードセグメントへジャンプするだけです。

この 4 バイトがどこから来るのか、パラメータがなぜ先頭にそのまま並ぶものと後方へ回って探すものに分かれるのか、そしてインターフェース定義なしに受け取った calldata をなぜ解けないのか——この 3 点はコントラクトの相互作用を理解する鍵です。本稿ではまず ABI(Application Binary Interface、アプリケーション・バイナリ・インターフェース)のエンコーディング規則を説明し、次に仕様文書が示すサンプル calldata を手作業でデコードしていき、最後にイベントログがなぜパラメータを topics と data という 2 つの位置に分けるのかを説明します。

セレクタは正規署名のハッシュを切り詰めたものであり、関数名そのものではない

Solidity ABI 仕様の関数セレクタ(function selector)の定義は短いものです。calldata の先頭 4 バイトであり、関数シグネチャの Keccak-256 ハッシュの最上位 4 バイトを取ったものです。ここでのシグネチャは正規形式を取り、ソースコード上の書き方とは同じではありません。関数名に、括弧で囲んだパラメータ型のリストを続け、型の間は単一のカンマで区切り、空白を入れず、パラメータ名も付けず、memory、calldata、storage のようなデータ位置修飾子も付けません。

正規形式では型の正規化も行われます。uint と int は uint256 と int256 と書かなければならず、address payable とコントラクト型はどちらも address、列挙型(enum)は uint8、ユーザー定義値型はその基底型として書きます。構造体(struct)は括弧で囲んだ構成要素の型、すなわちタプル(tuple)として書きます。S が uint256 のフィールドを 2 つ持つなら、function f(S memory s) のシグネチャは f((uint256,uint256)) となり、セレクタは keccak256("f((uint256,uint256))") の先頭 4 バイトから取ります。戻り値はシグネチャに含まれません。これは Solidity のオーバーロード解決と一致します。呼び出し側はパラメータだけで対象の関数を区別し、戻り値の違いで曖昧さを解消することはできません。シグネチャでは 1 文字ひとつが結果に影響し、空白が 1 つ増えるだけでも、uint を uint256 と書いても、セレクタはまったく別のものになります。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SelectorDemo {
    function selectorOfTransfer() external pure returns (bytes4) {
        // 引用符の中は正規シグネチャ:空白なし、パラメータ名なし、uint は uint256 に正規化
        return bytes4(keccak256("transfer(address,uint256)"));
    }
}

32 ビット空間がもたらす直接の帰結は、衝突が理論上の仮定ではないことです。2026 年 9 月時点で、4byte.directory では 0xa9059cbb というこの 1 つのセレクタに 6 件の候補シグネチャが登録されています。transfer(address,uint256) のほか、many_msg_babbage(bytes1)、workMyDirefulOwner(uint256,uint256)、func_2093253501(bytes) といったプレースホルダー名もあります。このデータベースの項目は誰でも投稿でき、件数と内容は時間とともに変化します。2 つの異なる関数がセレクタを共有すると、calldata の接頭辞だけでは呼び出しの意図を区別できず、対象コントラクトのコードかインターフェース定義によって判断するしかありません。

Solidity の公式ドキュメントはこの制限を独立した項目として挙げていませんが、solc は実装レベルで同一コントラクト内(継承チェーンを含む)のセレクタ重複を検査します。2 つの関数のセレクタが同じ場合、コンパイルは Function signature hash collision で失敗します(最小のコントラクトで再現できます)。コントラクトをまたぐ場合やバージョンをまたぐ場合にはこの検査はありません。これは、4byte 系のデータベースだけで関数名を逆引きすることが信頼できないことも示します。データベースが与えるのは候補の集合であり、衝突時には一意の答えを確定できません。

静的型はそのまま 1 ワードを占める

エンコーディングは 5 バイト目から始まります。規則はまずパラメータを静的型と動的型の 2 種類に分けます。静的型はその場でエンコードし、パラメータのバイト列に直接書き込みます。動的型は実際の内容を後方に置き、元の位置にはオフセットだけを残します。

EVM のワード長は 32 バイトで、静的型は一律で 1 ワードを占めます。uint<M> と int<M> はビッグエンディアンで書き込み、上位ビットを埋めます。符号なし数はゼロ埋めし、符号付き数は符号拡張して 0xff または 0x00 で埋めます。bool は uint8 と等価で、true は 1、false は 0 にエンコードされます。address は uint160 と等価で、20 バイトのアドレスは 32 バイトワードの下位に位置し、先頭に 12 個のゼロバイトが詰められます。固定長バイト型 bytes<M> は詰め方が逆で、値は左寄せになり、右側をゼロで 32 バイトまで埋めます。同じ静的型でも uint32(1) と bytes4(0x00000001) は同じワードの中で位置が異なり、前者は最後の 4 バイト、後者は最初の 4 バイトにあります。16 進ダンプを読むときに最も見間違えやすい箇所です。

固定長配列 <type>[M] も、要素の型が静的であれば同様に静的型に分類され、エンコード時はタプルとして展開し、M 個の要素を順に並べ、長さは書きません。構造体とタプルも同じように再帰的に展開します。仕様は静的型を「bytes、string、T[]、要素が動的型である T[k]、および動的メンバーを含むタプルを除くすべての型」と定義しており、この定義が「値を直接読むか、ポインタを追うか」を判断する唯一の根拠です。

calldata 自体にもガスコストがあり、しかも ABI の 32 バイト境界とは一致しません。EIP-2028 はトランザクションデータ内の非ゼロバイトのコストを 68 ガスから 16 ガスへ引き下げ、ゼロバイトは 4 ガスのままです。このコストはトランザクション固有のガスに属します。uint256 でごく小さな値を渡しても依然として 32 バイトを占め、その大半はゼロバイトで、コストは低いもののゼロではありません。逆に複数の小さな整数を bytes32 で詰めてパックすればガスは節約できますが、代償としてオンチェーンでは読めるパラメータ境界が失われ、デコード側はこの独自レイアウトを知っていなければなりません。

動的型はオフセットと長さの前置による二段階アドレッシングで辿る

bytes、string、T[]、および要素が動的型である固定長配列とタプルは、いずれも動的型に属します。長さは実行時の値に依存し、コンパイル時に固定できないため、パラメータのバイト列の中で固定幅を占めることはできません。

ABI はヘッド/テール分離(head/tail)レイアウトを採用します。すべてのパラメータの「ヘッド」をまず順に並べます。静的型のヘッドはその型自身のエンコーディングであり、動的型のヘッドは 32 バイトのオフセットです。オフセットの基準はパラメータブロックの先頭、つまりセレクタの直後のバイトであり、calldata 全体の先頭ではありません。これが手作業のデコードで最もよくあるずれの原因です。ヘッドの長さはパラメータの型だけで決まり、パラメータの値には依存しません。ヘッドの後ろに各動的パラメータの「テール」を順に置きます。テールの最初の項目は長さで、単位は型によって区別します。bytes と string はバイト数、配列は要素数であり、長さの後に内容が続きます。

string の長さは UTF-8 でエンコードした後のバイト数であり、文字数ではありません。そのため日本語や中国語、emoji は複数バイトを占めます。内容の後は 32 バイトの倍数までゼロ埋めします。配列の要素にも同じ規則を再帰的に適用し、ネストした配列(uint256[][] など)では各階層がそれぞれ長さとオフセットを持ち、オフセットは常にその階層のエンコードブロックの先頭を基準にします。仕様はこの設計の目的を明快に述べています。最悪の場合でも、ある値へアクセスする読み取り回数はパラメータ構造におけるそのネストの深さを超えず、また任意の要素のデータは相対アドレスだけに依存するため、全体をまとめて移動しても破綻しません。

見落とされやすい細部として、オフセットは最小であることも、データ領域が互いに重ならないことも要求されません。仕様が定義する厳格エンコーディングモード(strict encoding mode)はヘッドが詰まっていて空隙がないことを要求し、Solidity のエンコーダは常に厳格モードを出力しますが、デコーダは検査を強制しません。言い換えれば、意味が同じ呼び出しでも複数のバイト配置が存在し得ます。デコードの実装は、空隙のあるオフセットを受け入れるか、明示的に拒否するかのどちらかであり、この 2 つの選択はクライアント間の比較で差異を生みます。

calldata を 1 つ手作業でデコードする

デコード例は「Contract ABI Specification」の Examples 節からそのまま取ったものです。sam(bytes,bool,uint256[]) を呼び出し、パラメータは "dave"、true、[1,2,3] で、合計 292 バイトであり、32 バイトごとに改行すると次のようになります。

0xa5643bf2
0000000000000000000000000000000000000000000000000000000000000060
0000000000000000000000000000000000000000000000000000000000000001
00000000000000000000000000000000000000000000000000000000000000a0
0000000000000000000000000000000000000000000000000000000000000004
6461766500000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003

デコード手順は仕様の定義どおりに厳密に再現できます。

  1. 先頭 4 バイト 0xa5643bf2 はセレクタで、シグネチャ sam(bytes,bool,uint256[]) に対応します。シグネチャ内の uint は uint256 と書かなければならない点に注意してください。
  2. パラメータブロックにはヘッドが 3 つあり、それぞれ 32 バイトです。1 つ目のワードは 0x60、つまり 96 で、1 つ目のパラメータ bytes のオフセットです。2 つ目のワードは 0x01 で、bool は true です。3 つ目のワードは 0xa0、つまり 160 で、3 つ目のパラメータ uint256[] を指します。
  3. ヘッドは合計 96 バイトなので、1 つ目のパラメータのテールはパラメータブロックの 96 バイト目から始まり、0x60 と整合します。テールの 1 つ目のワードは 0x04 で、bytes が 4 バイトであることを示します。続く 4 バイトは 64617665 で、ASCII として dave と読めます。その後は 32 バイトまでゼロ埋めします。
  4. 1 つ目のパラメータのテールは 2 ワードで合計 64 バイトを占め、96 から 160 に及びます。したがって 2 つ目の動的パラメータのオフセットは 0xa0 です。その位置の 1 つ目のワードは 0x03 で、配列の要素が 3 つであることを示します。続く 3 つのワードはそれぞれ 1、2、3 です。

同じ手順は最小のデコーダとして書くこともできます。次の Python は上記の 1 種類のシグネチャだけを扱い、オフセットの単位がバイトであることと、長さの前置がどこにあるかを示すためのものです。

# 入力はセレクタ以降のパラメータブロックで、32 バイトごとにワードへ切り出す
def word(data, i):
    return int.from_bytes(data[i * 32:(i + 1) * 32], "big")

def decode_sam(args: bytes):
    bytes_off = word(args, 0)          # 1 つ目のワード:bytes パラメータの、パラメータブロック先頭からのバイトオフセット
    flag = word(args, 1) != 0          # 2 つ目のワード:bool
    arr_off = word(args, 2)            # 3 つ目のワード:uint256[] のオフセット

    n = word(args, bytes_off // 32)    # オフセットをワード添字に換算してから、長さの前置を読む
    s = args[bytes_off + 32: bytes_off + 32 + n].decode()

    k = word(args, arr_off // 32)      # 配列も同様に、まず要素数を読む
    items = [word(args, arr_off // 32 + 1 + j) for j in range(k)]
    return s, flag, items              # ("dave", True, [1, 2, 3])

ABI は自己記述的ではなく、schema はオフチェーンに必須の部品

仕様は冒頭で明記しています。このエンコーディングは自己記述的ではなく、デコードには schema が必須です。この一文が工学的に意味する重みは、見た目以上に大きいものです。

EVM が受け取るのはバイト列だけで、関数名、パラメータ名、型情報はコンパイル後にすべて消えます。ディスパッチは 4 バイトのセレクタの比較で行い、ジャンプ先はコンパイラが生成した switch です。戻りデータも同じくバイト列にすぎず、呼び出し側が動的な長さの戻り値を読むには RETURNDATASIZE や RETURNDATACOPY といった操作が必要です(EIP-211 で提案され、Byzantium アップグレードで有効化)。ABI JSON を置く場所はオンチェーンにはなく、コンパイル成果物としてソースと一緒に公開するしかありません。ここからいくつかの具体的な制約が生じます。

ブロックエクスプローラーが「どの関数が呼ばれ、パラメータは何か」を表示するには、まず検証済みのソースと ABI を取得しなければなりません。未検証のコントラクトは生の calldata を表示するか、4byte 系のデータベースで逆引きするしかなく、逆引きで得られるのは候補シグネチャの集合で、衝突時には確定できません。インデクサやデータパイプラインはデプロイ時に ABI を知っていなければならず、そうでなければイベントや呼び出しのインデックスを作れません。インターフェースの進化にもフィールド番号のような互換機構はありません。関数にパラメータを追加、削除、並べ替えるとセレクタが変わり、呼び出し側にとっては完全なインターフェース破壊になります。そのためアップグレードは通常、既存の関数を変更するのではなく新しい関数を追加する形になります。プロキシコントラクトは実装層で関数を追加できますが、セレクタは安定させなければなりません。シグネチャを変える「リファクタリング」は、既存の呼び出しを fallback 分岐へ落とし、fallback は生の calldata しか受け取れないため、呼び出し側の意図を復元できません。

もう 2 点はよく誤解されます。セレクタが同じであることはエンコーディングが互換であることを意味しません。transfer(address,uint256) と many_msg_babbage(bytes1) は 0xa9059cbb を共有しますが、パラメータの長さも意味もまったく異なり、ツールは衝突時にコントラクトのコードや calldata の長さを組み合わせて判断しなければなりません。カスタムエラー(custom error)のセレクタも同様にエラーシグネチャの先頭 4 バイトから取るため、どのコントラクトも特定のエラーシグネチャに一致するデータを返せます。仕様はそのため、エラーデータを信頼できる出所の情報と見なさないよう呼び出し側に明確に注意を促しており、あくまでヒントとして扱うのに適するだけです。

イベントは検索性を topics に、可読性を data に置く

イベントのエンコーディング規則は関数呼び出しと同源ですが、着地点は異なります。1 件のログはコントラクトアドレス、最大 4 つのトピック(topic)、任意長のデータから構成されます。非匿名イベントの topics[0] は常にイベントシグネチャの Keccak-256 ハッシュで、4 バイトへの切り詰めは行いません。これが eth_getLogs でイベントを絞り込むときに、条件としてイベント名ではなくシグネチャのハッシュを書く理由です。イベントシグネチャも正規形式を使い、uint は uint256 に正規化されます。

残りのパラメータは indexed の有無で 2 つの経路に分かれます。indexed が付いた値型のパラメータは直接 topics[1] から topics[3] へエンコードします。整数は上位ビットを埋め、固定長バイトは右側を埋め、アドレスは下位 20 バイトを取ります。非匿名イベントの indexed パラメータは最大 3 つで、シグネチャのトピックを加えるとちょうど 4 つを使い切ります。anonymous と宣言したイベントはシグネチャのトピックを書かず、indexed パラメータは最大 4 つになりますが、代償としてシグネチャによるフィルタリング能力を失います。indexed が付かないパラメータは ABI の規則に従って data へエンコードされ、このデータの内部にも同じヘッド/テール構造があり、動的パラメータはそこでもオフセットによってアドレッシングされます。

差異は indexed の複合型に集中して現れます。配列、string、bytes、構造体を indexed として指定すると、topic に書き込まれるのはそのエンコーディングの Keccak-256 ハッシュです。ハッシュは不可逆なので、こうしたパラメータは正確に検索できます。目標値のハッシュを事前に計算しておけば、それをフィルタ条件としてログに一致させられます。しかしログから元の値を復元することはできず、一致したかどうかを確認できるだけです。仕様が示す対処は、同じ値を保持するパラメータを 2 つ宣言し、片方を indexed にして検索に、もう片方を indexed にせず読み取りに使う方法で、代償はログサイズとガスの増加です。ハッシュのエンコーディング規則自体にも曖昧さが残ります。構造体が複数の動的配列を含む場合、連結後のバイト列が一意にならないことがあり、仕様は indexed パラメータの検索結果だけでイベントの意味を断定しないよう注意を促しています。

最後に「検索できること」と「デコードできること」を区別しておきます。値型の indexed パラメータは直接読み出せますが、動的型の indexed パラメータは一致させるだけで読み出せません。indexed でないパラメータは読み出せますが、値による検索はできません。ログが topics と data を分けているのは、ログサイズ、検索能力、デコード能力の間で折り合いを付けるためであり、どの種類のパラメータもすべての性質を同時に得ることはできません。

出典

  • Solidity ドキュメント「Contract ABI Specification」、関数セレクタ、ヘッド/テールレイアウト、厳格エンコーディングモード、イベントと indexed パラメータのエンコーディング仕様、sam のデコード例:https://docs.soliditylang.org/en/latest/abi-spec.html
  • 4byte.directory 上の 0xa9059cbb の候補シグネチャ一覧(2026 年 9 月に照会。項目は誰でも投稿可能):https://www.4byte.directory/signatures/?bytes4_signature=0xa9059cbb
  • EIP-609「Hardfork Meta: Byzantium」、EIP-211 と EIP-214 を Byzantium の包含リストに加えるメタ提案:https://eips.ethereum.org/EIPS/eip-609
  • EIP-2028「Transaction data gas cost reduction」、非ゼロの calldata バイトを 68 ガスから 16 ガスへ引き下げる:https://eips.ethereum.org/EIPS/eip-2028
  • EIP-211「New opcodes: RETURNDATASIZE and RETURNDATACOPY」、動的な戻りデータの読み取りと BYZANTIUM_FORK_BLKNUM:https://eips.ethereum.org/EIPS/eip-211
  • Ethereum Yellow Paper、付録 G の料率表における G_txdatazero の 4 ガスと G_txdatanonzero の 16 ガス:https://ethereum.github.io/yellowpaper/paper.pdf
  • ethereum.org の EVM オペコードリファレンスと wolflo/evm-opcodes の動的ガス表、トランザクション固有ガスにおけるゼロ/非ゼロバイトの課金:https://ethereum.org/en/developers/docs/evm/opcodes/ 、https://github.com/wolflo/evm-opcodes/blob/main/gas.md

関連記事

EVM 互換とは実際に何を意味するのか:バイトコード、プリコンパイル、JSON-RPC、ツールチェーンBitroot の楽観的並列化:検出、再実行、決定性

呼び出し命令とコンテキストの関係については、0.17「CALL 族の対照:CALL、CALLCODE、DELEGATECALL、STATICCALL」を参照してください。

← 前へ単一スレッドのグローバル状態:EVM 性能ボトルネックの設計原点
目次
セレクタは正規署名のハッシュを切り詰めたものであり、関数名そのものではない静的型はそのまま 1 ワードを占める動的型はオフセットと長さの前置による二段階アドレッシングで辿るcalldata を 1 つ手作業でデコードするABI は自己記述的ではなく、schema はオフチェーンに必須の部品イベントは検索性を topics に、可読性を data に置く出典関連記事
閲覧設定