対戦履歴の一覧を作ります。「このアドレスが関わったゲームを全部出す」という、どこにでもある画面です。

しかしこれは、チェーン上のアプリでは設計判断が必要な機能になります。SQL の WHERE player_id = ? に相当するものが、そのままの形では存在しないからです。

まず: 読むだけならウォレットは要らない

前提として押さえておきたいことがあります。履歴表示に必要な権限はゼロです。

チェーンの状態はすべて公開されているので、必要なのは eth_calleth_getLogs だけ。どちらも署名も認証も要りません。任意のアドレスの戦績ページを、その持ち主の許可なく作れます。

署名が要るのは書き込み(着手・参加・引き出し)のときだけです。読むページと書くページで必要な権限がまったく違う、というのは Web 開発とかなり違う感覚です。

選択肢は 4 つある

方法できること限界
getGames() で全件読んで JS で絞る単純。追加インフラ不要O(n)。件数が増えると破綻する
eth_getLogs + indexed トピックノード側で絞り込む。追加インフラ不要indexed にした引数だけ。join・集計・ソート不可
オンチェーンの索引 mapping(address => uint256[])1 回の eth_call で正確に取れる。最速書き込みのたびにガスを払う
サブグラフ / インデクサjoin、集計、ソート、ページング、GraphQL別インフラの運用。同期遅延。信頼点が増える

読むときに索引を引く」か「書くときに索引を作る」か、という構図は Web 開発の DB インデックスと同じです。違うのは、書き込み時のインデックス作成に実際にお金がかかることです。配列に 1 件 push するだけで 20,000 ガス超。だから安易に mapping(address => uint256[]) を足しません。

本書は 2 番目、eth_getLogs の indexed トピックで実装します。

indexed は「後から変えられない索引」

イベントの引数に indexed を付けると、その値がログのトピックとして記録され、ノード側で検索対象になります

event GameCreated(
    uint256 indexed gameId,
    address indexed playerX,
    address indexed invitedOpponent,
    uint256 stake,
    uint32 timeout,
    uint64 joinDeadline
);

制約が 2 つあります。

1. 非匿名イベントでは indexed は最大 3 つ。 トピックは 4 枠しかなく、topic0 がイベントのシグネチャハッシュで埋まるためです。上の GameCreated は 3 枠を使い切っています。

2. 参照型(stringbytes、配列、構造体)を indexed にすると、値ではなくハッシュが入ります。 一致検索はできますが、元の値は取り出せません。

そして最大の制約は、デプロイ後に変更できないことです。「後から winner でも絞りたくなった」と思っても、既存のコントラクトのイベント定義は変えられません。どの 3 つを索引にするかは、リリース前に決めきる必要があります。

実際に投げているクエリ

抽象論より現物です。「アドレス 0x3Dc1fd…24DC が始めた CPU 戦を全部」を取るリクエストは、こうなります。

{
  "jsonrpc": "2.0", "id": 1, "method": "eth_getLogs",
  "params": [{
    "address": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512",
    "fromBlock": "0x0",
    "toBlock": "latest",
    "topics": [
      "0xbab75bd58d350bd547c0e6d93f85015c83bd786d046bc1be130ab77e58cd449f",
      null,
      "0x0000000000000000000000003dc1fd528cfef3280f7cd056b47efebea45b24dc"
    ]
  }]
}

topics 配列の中身を 1 つずつ見てください。

位置意味
topics[0]0xbab75bd5…keccak256("GameCreated(uint256,address,uint8,bool,uint8)")イベントの種類
topics[1]nullgameIdnull = 何でもよい
topics[2]0x000…3dc1fd…24dcplayerアドレスを 32 バイトに左ゼロ詰めしたもの

アドレスがそのままクエリの条件になっています。 20 バイトのアドレスを 32 バイトに詰めるだけです。

topics[0]cast keccak で自分でも計算できます。

cast keccak "GameCreated(uint256,address,uint8,bool,uint8)"
# → 0xbab75bd58d350bd547c0e6d93f85015c83bd786d046bc1be130ab77e58cd449f

応答はこうでした。

2 件ヒット
  gameId=0  block=15  tx=0x3ac58e5639b3cc8c…
  gameId=1  block=21  tx=0x37b5b51e47534ef7…

クライアント側で全件取ってから絞ったのではありません。 ノードがブルームフィルタとトピックの索引を引いて、該当するログだけを返しています。

cast からも同じことができますが、イベントシグネチャを渡す形式ではトピックの絞り込みが効きません(Foundry 1.7.1 で確認。エラーも出ず、全件が返ります)。トピックを 16 進で直接渡す形式を使ってください。

cast logs --address 0xe7f1... --from-block 0 \
  0xbab75bd58d350bd547c0e6d93f85015c83bd786d046bc1be130ab77e58cd449f \
  "" "$(cast to-uint256 0x3Dc1fd...)"

空文字列が null(何でもよい)に対応します。

viem から呼ぶ

getContractEvents を使うと、ABI から topic0 を計算し、args をトピックに変換し、返ってきたログをデコードするところまで面倒を見てくれます。

const logs = await client.getContractEvents({
  address: soloContract,
  abi: soloTicTacToeAbi,
  eventName: 'GameCreated',
  args: { player: address },   // ← これが topics[2] になる
  fromBlock,
});

args: { player: address } の 1 行が、上の生 JSON の topics[2] そのものです。args に指定できるのは indexed を付けた引数だけで、それ以外は TypeScript が型で弾きます。ただし実行時のチェックはありません。型を回避して非 indexed の引数を渡すと、viem はそのキーを黙って無視し、絞り込みなしのクエリを投げます。「絞れているつもりで全件取っている」になるので注意してください。

対人戦は役割が 2 つ(作った側と参加した側)あるので、イベントごとにリクエストを分けます。

// 自分が作ったゲーム
client.getContractEvents({ address: pvp, abi, eventName: 'GameCreated', args: { playerX: address }, fromBlock })
// 自分が参加したゲーム
client.getContractEvents({ address: pvp, abi, eventName: 'GameJoined',  args: { playerO: address }, fromBlock })

join がないので、役割の数だけリクエストが増えます。 これが eth_getLogs の素朴さであり、限界でもあります。

ログは「何が起きたか」、状態は「いまどうなっているか」

ログから分かるのは gameId までです。現在の勝敗や賭け金は、状態を読み直す必要があります。

行数分の eth_call を投げると遅いので、multicall でまとめます。wagmi の useReadContracts がこれをやってくれます。

const { data: states } = useReadContracts({
  contracts: entries.map((entry) => ({
    address: pvp, abi: ticTacToeAbi, functionName: 'getGame', args: [entry.gameId],
  })),
});

内部的には Multicall3 — 300 以上のチェーンに同じアドレス 0xcA11bde05977b3631167028862bE2a173976CA11 で置かれているコントラクト — への 1 回の eth_call になります。N 件の読み取りが 1 リクエストで済みます。

ただしローカルの anvil には置かれていません(viem のチェーン定義にも登録がありません)。その場合 wagmi は N 回の eth_call に展開してフォールバックします。結果は同じで、リクエスト数だけが増えます。

つまり構成はこうなります。

eth_getLogs(トピックで絞る)→ gameId のリスト
        ↓
multicall(1 回)→ 各ゲームの現在の状態

fromBlock の落とし穴

ローカルの anvil なら fromBlock: 0 で構いません。数十ブロックしかないからです。

しかし公開 RPC は eth_getLogs のブロック範囲に上限を設けています(多くは 1 万〜10 万ブロック、無料プランではもっと厳しい)。メインネットやテストネットで fromBlock: 0 を投げると、素直にエラーが返ります。

対処は「コントラクトをデプロイしたブロック番号を控えておく」ことです。それ以前にイベントは存在し得ません。

NEXT_PUBLIC_DEPLOY_BLOCK_84532=12345678

範囲がそれでも広すぎる場合は、チャンクに分割して複数回投げます。この面倒さが、実際のところサブグラフを導入する一番よくある理由です。

サブグラフが要るのはいつか

eth_getLogs で表現できないものが必要になったときです。

  • 集計 — 「勝率ランキング」「直近 30 日の総ポット額」
  • join — 「対戦相手ごとの成績」
  • 任意フィールドでのソート・ページング — 「賭け金の大きい順に 20 件」
  • indexed でない値での絞り込み — 「賭け金が 1 ETH 以上のゲーム」
  • 長い履歴 — ブロック範囲の上限を超える走査

The Graph、PonderEnvio といった選択肢があります。いずれもイベントを購読して自前の Postgres に入れ、GraphQL や SQL で引けるようにするという仕組みです。

ただし忘れてはいけないのは、インデクサは信頼点を 1 つ増やすということです。チェーンから直接読んでいる限り、答えはチェーンが保証します。インデクサを挟むと、「そのインデクサが正しく同期しているか」が新たな前提になります。同期遅延で古いデータを見せる事故も起きます。

だから本書のように索引で足りる範囲なら、挟まないほうが健全です。

開発者向けのイベント一覧

もう一つ、地味に効くものを作りました。コントラクトが発行したイベントを、そのまま表に出すパネルです。

const logs = await client.getContractEvents({ address, abi, fromBlock, toBlock: 'latest' });

イベント名、ブロック番号、引数、トランザクションハッシュを並べるだけ。エクスプローラのあるチェーンではハッシュをリンクにし、ローカルの anvil ではハッシュをそのまま出します。

// src/lib/explorer.ts
function chainOf(chainId: number | undefined): Chain | undefined {
  return SUPPORTED_CHAINS.find((chain) => chain.id === chainId);
}

export function txUrl(chainId: number | undefined, hash: string): string | undefined {
  const base = chainOf(chainId)?.blockExplorers?.default.url;
  return base ? `${base}/tx/${hash}` : undefined;
}

同じ形で addressUrl(アドレスのページ)と explorerName(「Etherscan で見る」のラベル)も書けます。

viem のチェーン定義に blockExplorers が入っているので、URL を自分で持つ必要はありません。anvil には定義がないので undefined が返り、リンクにならない。分岐が自然に書けます。

このパネルは開発中のデバッグに一番効きました。「着手したのに盤面が変わらない」ようなとき、イベントが出ているかどうかを見れば、コントラクトまで届いていないのか、フロントの読み直しが漏れているのかが一目で分かります。

そして忘れがちですが、これはアプリが保持しているログではありません。チェーンが「何が起きたか」を答えているだけです。アプリを消してもデータは残りますし、別の誰かが同じものを表示できます。それがオンチェーンにアプリを載せるということです。

名前はどこにあるのか

イベントを一覧に出すと、MovePlayed GameCreated といった名前が並びます。しかしその名前は、チェーンのどこにも書かれていません

まず、チェーンに実際に保存されているログを生で見ます。

address:    0x837b592cbbdcf3a3442e06afd6a522c596bea6fe
topics[0]:  0x3dffe374a236c868205711a3ffcfcfdb550299d98d77417ec512d6e250ccc414
topics[1]:  0x0000…0000                                     ← gameId = 0
topics[2]:  0x0000…3dc1fd528cfef3280f7cd056b47efebea45b24dc  ← player
data:       0x…0004  0000  0000                              ← 4, 0, 0

topics[0] の正体はこれです。

$ cast keccak "MovePlayed(uint256,address,uint8,uint8,uint8)"
0x3dffe374a236c868205711a3ffcfcfdb550299d98d77417ec512d6e250ccc414

イベントのシグネチャを keccak256 したものでした。第 4 章で見た関数セレクタとまったく同じ構造で、違いは長さだけです(関数は先頭 4 バイト、イベントは 32 バイト全部)。

バイトコードを探しても名前は無い

デプロイ済みのバイトコードを直接調べると、こうなります。

デプロイ済みバイトコード: 5,424 バイト

'MovePlayed'       ✗ 含まれない
'inputs'           ✗ 含まれない
'stateMutability'  ✗ 含まれない
'gameId'           ✗ 含まれない

ABI を構成する文字列が 1 つも入っていません。 コンパイルの過程で名前は捨てられ、残るのは機械語だけです。引数の名前も同じで、data はただの 96 バイト。「先頭 32 バイトが 1 番目の引数」という位置の約束しかありません。

チェーン上とチェーン外を破線で分けた図。左にバイトコード・topics・data・末尾の付箋、右に ABI・ソースコード・その置き場所。付箋だけが境界をまたいで右を指している

名前はフロントエンドの中にある

では画面の「MovePlayed」はどこから来たのか。公開したアプリの JS バンドルを grep すると、出てきます。

// /_next/static/immutable/chunks/33xgc3ehaxmj4.js
…,type:"uint8",indexed:!1}],name:"MovePlayed"},{type:"error",inputs:[],name:"BoardFull"},…

npm run wagmiforge build の出力から生成した ABI が、そのまま JS に焼き込まれています。viem はこの ABI から各イベントのハッシュを計算し、チェーンから取れた topics[0] と突き合わせて名前を出しています。

ABI を持たないクライアントには、32 バイトの数字にしか見えません。

末尾 51 バイトの付箋

ただし、チェーン上にまったく手がかりが無いわけではありません。バイトコードの末尾に、CBOR で書かれた小さな節が付いています。

末尾 2 バイト = 51 → その手前 51 バイトがメタデータ節

a2                  map, 2 要素
64 69706673         text(4) = 'ipfs'
5822 1220c3b77c49…  bytes(34) = IPFS の multihash
64 736f6c63         text(4) = 'solc'
43 00081e           bytes(3) = 0.8.30
コンパイラsolc 0.8.30
メタデータの所在IPFS: QmbWeV9X1SYBqRq9vXKh1psToS3VPyhkjHBKenHq821MyM

Solidity はコンパイル時にメタデータ JSON(ABI・ソース・コンパイラ設定を含む)を作り、その IPFS ハッシュだけをバイトコードに埋め込みます。 チェーンにあるのは住所であって、中身ではありません。しかも誰かが実際に IPFS へ公開していなければ、その住所には何もありません。Sourcify がやっているのは、まさにその公開作業です。

コントラクト側からは照合できない

ここは重要です。コントラクトから他のコントラクトについて分かるのは、次の 2 つだけです。

extcodesize   5,424 バイト        ← コードの長さ
extcodehash   0xf5bc55c1…61fe48   ← コードのハッシュ

関数名もイベント名も取得できません。そもそもチェーン上に無いのだから当然です。加えて、

  • コントラクトは自分が出したイベントも読み返せません(第 2 章)
  • 他のコントラクトを呼ぶには、呼ぶ側がコンパイル時にインターフェースを知っている必要があります

唯一それらしいのが ERC-165supportsInterface(bytes4) ですが、これは内省ではなく自己申告です。作者が「対応しています」と返す関数を自分で書いているだけで、嘘もつけます。しかも interfaceId は関数セレクタの XOR であって、名前ではありません。

EVM は名前が見えないまま動きます。 扱えるのは 4 バイトのセレクタと 32 バイトのトピックハッシュだけです。

どこで照合されているか

照合場所
0x3dffe374…MovePlayedオフチェーン(アプリの ABI / Etherscan の DB)
0x5cd7692eplay(uint256,uint8)オフチェーン(ABI / 4byte.directory)
バイトコード → ソースコードオフチェーン(Sourcify / Etherscan)
アドレス → 人間可読な名前オンチェーン(ENS。ただし別レイヤー)

人間に読める情報は、ほぼすべてチェーンの外にあります。 チェーンが保証しているのは「このバイト列が、このハッシュで、この順序で記録された」ことだけ。意味を与えているのは外側のインフラです。

帰結が 3 つあります。

  1. ABI を失うと、自分のコントラクトが読めなくなります。 バイトコードは永久に残りますが、何ができるのか分からない箱になります
  2. verify の意味は、チェーン上のバイトコードと外のソースを、公開の場で結びつけることにあります
  3. src/generated/wagmi.ts を手で編集してはいけないのも同じ理由です。ABI がずれると、チェーンは正常なのにアプリだけが誤読します

ハッシュの連鎖を目で見る

もう一歩進めて、1 局が刻まれたブロックの連なりを描いてみます。「ブロックチェーン」という語が何を指しているのかが、図で一発で分かります。

ブロックのヘッダには parentHash というフィールドがあり、直前のブロックのハッシュがそのまま入っています。確かめてみましょう。

for n in 9 10 11; do
  cast block $n --json | python3 -c 'import sys,json; b=json.load(sys.stdin); print(b["number"], b["hash"][:14], b["parentHash"][:14])'
done
#9   hash=0xd3168496643d…  parentHash=0xb5dda0780dfc…
#10  hash=0xb1c0615caaff…  parentHash=0xd3168496643d…   ← #9 の hash
#11  hash=0xdf505995f729…  parentHash=0xb1c0615caaff…   ← #10 の hash

ブロック N の parentHash が、ブロック N−1 の hash と一致しています。

ここから改竄不可能性が導かれます。ブロック #9 の中身を 1 ビットでも変えれば、#9 のハッシュが変わる。すると #10 の parentHash が合わなくなる。#10 を辻褄合わせに書き換えれば #10 のハッシュも変わり、#11 が壊れる。過去を書き換えるには、そこから現在までの全ブロックを作り直す必要があるわけです。

ここまではハッシュ関数の性質から機械的に導かれます。しかし「作り直せない」ことはハッシュからは出てきません。 作り直しを高くつかせているのは合意形成の経済的な仕組み(ステーク、証明、没収)のほうです。実際、いま手元で叩いている anvil では作り直しはタダで、何の保証もありません。

「ハッシュが繋がっている=安全」ではなく、ハッシュが繋がっているので、書き換えれば検出できる。書き換えを割に合わなくしているのは別のレイヤー、と理解してください。

同じ理由で、取り込まれたトランザクションが後から巻き戻ることはあり得ます(リオーグ)。金額の大きい処理では、レシートを 1 通受け取っただけで確定扱いにせず、何ブロックか待つのが実務です。

実装

必要なデータは 2 段階で取れます。

// 1. このゲームのイベントを取り、載っているブロック番号を集める
const logs = await client.getContractEvents({ address, abi, fromBlock, toBlock: 'latest' });
const mine = logs.filter((log) => log.args.gameId === gameId);

// 2. そのブロックを取る
const blocks = await Promise.all(numbers.map((number) => client.getBlock({ blockNumber: number })));

あとは隣接判定をするだけです。

linksToPrevious: previous ? block.parentHash === previous.hash : false,
gapBefore: previous ? block.number - previous.number - 1n : 0n,

linksToPrevious が真なら「✓ 直前のブロックと一致」、偽なら「間に N ブロック」と出します。間が空いている場合も鎖は繋がっていますが、その間のブロックには他人の取引が載っている。それも含めて見せると、「自分の取引が他人の取引と同じ 1 本の鎖に編み込まれている」ことが伝わります。

描き方

SVG で凝った図を描くより、カードを縦に並べて、間に接続線を引くほうが実用的でした。

  • ハッシュは 66 文字あるので、横並びだとすぐ溢れる
  • スマートフォンでも読めるようにしたい
  • 値そのものをコピーできる形で出したい(<code> にしておく)
<ol>
  {blocks.map((block, index) => (
    <li key={block.hash}>
      {index > 0 && <Connector block={block} />}
      <BlockCard block={block} chainId={chainId} />
    </li>
  ))}
</ol>

Connector は破線の縦線と、parentHash → 0xdf50… という表示、そして一致バッジだけです。CSS の border-l-2 border-dashed で線を引いています。

一つだけ気をつけること

表示するハッシュは、必ずチェーンから読んだ値にしてください。 アプリ側で再計算して見せると、それは「アプリがそう主張している」だけになり、可視化の意味が半減します。

本書の実装は eth_getBlockByNumber の応答をそのまま出しています。だから画面に出ている hashparentHash は、cast block で叩いた値と 1 文字も違いません。読者が手元で検証できる、というのが大事なところです。