3MIKAN
仮想通貨直コン

ABI・function selector・calldata・event logsを手で読む方法

ABI、4-byte function selector、calldata、event topic、logsの構造を、固定したERC-20 transferの例とviem APIで解説します。

ERC-20 transferの関数の目印、32バイト引数、event topics、dataを対応させる図

ExplorerでInput DataLogsを開くと、長い16進数が並びます。最初は意味のない文字列に見えますが、区切り方を知れば「どの関数を、誰に対して、どの数量で実行したか」を確認できます。

イメージは、宛名や金額が決まった書類を、コンピュータが読める決まった幅へ並べ直したものです。ABIは、その書類の各欄をどう読むか示す説明書です。

ERC-20 transferの入力データとTransfer event logを関数の目印、topics、dataへ分解した図

ABIはデータを読むための型情報

ABI形式のデータは、それだけでは各値の意味を説明してくれません。同じ32バイト列でも、uint256addressbytes32のどれとして読むかで意味が変わります。

その読み方を決めるのがABIです。関数の目印を検索した結果だけで断定せず、コントラクトの検証済みソースコードや、現在の実装に対応するABIと照合します。

function selectorは先頭4バイトの目印

関数名と引数の型を決められた書式で並べ、Keccak-256ハッシュの先頭4バイトを使います。この4バイトが、呼び出す関数の目印(function selector)です。

transfer(address,uint256)
        ↓ Keccak-256
0xa9059cbb...
        ↓ first 4 bytes
0xa9059cbb

ERC-20のtransfer(address,uint256)を呼び出す入力データ(calldata)は、次の形になります。

0xa9059cbb
0000000000000000000000001111111111111111111111111111111111111111
000000000000000000000000000000000000000000000000000000000001e240
  • 0xa9059cbb: 呼び出す関数の目印
  • 1つ目の32バイト: 送金先のaddress。20バイトの値を左側からゼロで埋めている
  • 2つ目の32バイト: 送る数量。この例では0x1e240 = 123456

つまり、この例は「0x1111...1111へ123456単位を送る」という指示です。小数点以下の桁数はトークンごとに違うため、人が見る数量へ直すときはdecimalsも確認します。

viemで入力データをデコードする

import { decodeFunctionData } from 'viem'

const decoded = decodeFunctionData({
  abi: erc20Abi,
  data,
})

console.log(decoded.functionName, decoded.args)

手で読む方法は検算に向いています。ただし、tuple、array、string、bytesなどの可変長データでは、別の位置を指すoffsetとデータ長も追う必要があります。

実装ではABIライブラリを使い、手読みは「関数の目印、32バイトの境界、アドレス、整数が不自然でないか」を確認する用途に限定します。

動的型はheadとtailに分かれる

stringや可変長bytesは、その位置に実データを直接置きません。最初の部分(head)には実データの位置を示すoffsetを置き、後ろの部分(tail)へ長さとデータを置きます。

head: offset to tail
...
tail: length | data | padding

たとえば、申込書の欄へ長い文章を直接書かず、「詳細は別紙のこの位置」と示すイメージです。

offsetは入力データ全体の先頭ではなく、ABI引数ブロックの先頭を基準にする点に注意してください。

event logsは「処理中に発行された記録」

event logsは、コントラクトが処理中に発行する記録です。送金や交換が行われたときに、関係するアドレスや数量を後から検索しやすい形で残します。

non-anonymous eventでは、topics[0]にevent署名のKeccak-256ハッシュが入り、indexedに指定された引数が続きます。indexedでない引数はdataへABI形式で格納されます。

ERC-20のTransfer(address indexed from, address indexed to, uint256 value)なら、次の形です。

topics[0] = keccak256("Transfer(address,address,uint256)")
topics[1] = from
topics[2] = to
data      = value
import { parseEventLogs } from 'viem'

const transfers = parseEventLogs({
  abi: erc20Abi,
  logs: receipt.logs,
  eventName: 'Transfer',
})

indexedの動的型は元データへ戻せない

stringbytesなどの動的型をindexedにすると、topicには元の値ではなく、特殊な形式で作ったKeccakハッシュが入ります。そのため、topicだけから元の文字列を復元することはできません。

logsだけで成功を断定しない

ログが見つかっただけで、取引全体の成功を断定しないでください。最終的には、実行結果(transaction receipt)のstatus、コントラクトアドレス、ブロック番号、log indexも確認します。

別コントラクトが同名のeventを発行している、proxy経由で実行された、誤ったABIで読んでいる、といった可能性があるためです。

トランザクション全体の解析は実行結果・入力データ・エラーの確認順、エラーの目印はcustom errorのデコードへ進んでください。直コンで書き込みを行う前にも、送信先、関数の目印、引数を確認する習慣が役立ちます。

小さな例で検算する

上のERC-20送金例では、次の4点が一致すれば読み方は合っています。

  1. 先頭4バイトがtransfer(address,uint256)の目印である
  2. 1つ目の32バイトから送金先0x1111...1111を復元できる
  3. 2つ目の32バイトが10進数の123456になる
  4. Transferログの送信先と数量が、実行結果の内容と矛盾しない

最初から長いデータをすべて手で読む必要はありません。まず、関数の目印、アドレス、数量の3点だけをライブラリのデコード結果と見比べると、入力先や桁数の間違いに気づきやすくなります。

確認した一次情報