Skip to content
On this page

L1-17: Indexer(インデクサー)

1. 課題

独自のオンチェーンデータインデクサーをゼロから構築します。The Graph や Dune を使用せず、コントラクトイベントを直接リッスンし、ローカルデータベースに保存し、フロントエンドがクエリするための REST API を提供します。目標:ERC-20 Transfer イベントをインデックスし、アドレスおよびトークンコントラクトによる転送記録の照会をサポートします。

2. なぜ重要か

The Graph のホステッドサービスはダウンタイムや遅延が発生する可能性があります。自前のインデクサーは完全な制御を提供します。

  • 柔軟性:The Graph がサポートしないカスタムロジック(複雑な集計、クロスコントラクト関連付け)を実装できる
  • リアルタイム性:WebSocket イベントプッシュを直接リッスンし、The Graph のポーリングモデルよりも低遅延
  • 基盤原理の理解:自前のインデクサーを構築することで、The Graph が行っていること(イベントのリッスン → 解析 → 保存 → クエリ)を理解できる
  • データ所有権:インデックスデータは自前のデータベースに保存され、サードパーティサービスの可用性に影響されない

これはバックエンドエンジニアが web3 に移行するための重要なスキルでもあります。

3. 解決策

アーキテクチャ設計

Ethereum ノード (WebSocket)


watchEvent (viem) ──→ Event パラメータを解析


SQLite データベース ──→ Transfer レコードを保存


Express REST API ──→ /api/transfers, /api/balance/:address

データベーススキーマ

sql
CREATE TABLE IF NOT EXISTS transfers (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  tx_hash TEXT NOT NULL,
  block_number INTEGER NOT NULL,
  log_index INTEGER NOT NULL,
  from_address TEXT NOT NULL,
  to_address TEXT NOT NULL,
  token_address TEXT NOT NULL,
  amount TEXT NOT NULL,
  timestamp INTEGER NOT NULL,
  UNIQUE(tx_hash, log_index)
);

CREATE TABLE IF NOT EXISTS indexer_state (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL
);

コアインデックスロジック

javascript
async function processEvents(fromBlock, toBlock) {
  for (const tokenAddress of WATCHED_TOKENS) {
    const logs = await client.getLogs({
      address: tokenAddress,
      event: ERC20_TRANSFER_EVENT,
      fromBlock: BigInt(fromBlock),
      toBlock: BigInt(toBlock),
    });

    const insert = db.prepare(`
      INSERT OR IGNORE INTO transfers
      (tx_hash, block_number, log_index, from_address, to_address,
       token_address, amount, timestamp)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
    `);

    const insertMany = db.transaction((logs) => {
      for (const log of logs) {
        insert.run(
          log.transactionHash,
          Number(log.blockNumber),
          log.logIndex,
          log.args.from.toLowerCase(),
          log.args.to.toLowerCase(),
          log.address.toLowerCase(),
          log.args.value.toString(),
          Math.floor(Date.now() / 1000),
        );
      }
    });
    insertMany(logs);
  }
}

チェーン再編成の処理

javascript
async function handleReorgs() {
  const lastBlock = await getLastIndexedBlock();
  const safeBlock = Math.max(0, lastBlock - 12);
  db.prepare('DELETE FROM transfers WHERE block_number > ?').run(safeBlock);
  await updateLastIndexedBlock(safeBlock);
}

REST API

  • GET /api/transfers?address=0x...&limit=100 -- アドレス別に転送記録を照会
  • GET /api/transfers?token=0x...&limit=100 -- トークンコントラクト別に照会
  • GET /api/balance/:address -- 特定アドレスのインデックス残高を照会

4. 遭遇した落とし穴

4.1 WebSocket の切断と再接続

Ethereum ノードへの WebSocket 接続はネットワークの問題で切断される可能性があります。自動再接続とチェックポイントからの再開を実装しないと、切断中に発生したすべてのイベントをインデクサーが見逃します。

4.2 チェーン再編成(Reorg)

Ethereum では時折チェーン再編成が発生し、以前に確定されたブロックがロールバックされます。それらのブロックのイベントとしてインデクサーに保存されたデータも無効になります。最も保守的な戦略は、最新ブロックから12ブロック以上遅れたデータのみをインデックスすることです(「finalized」状態)。

4.3 重複排除

同じイベントがリトライや reorg 処理によって複数回インデックスされる可能性があります。UNIQUE(tx_hash, log_index) 制約と INSERT OR IGNORE の組み合わせが最もシンプルな重複排除戦略です。

4.4 大きな数値における JavaScript の精度問題

ERC-20 の amount は任意の大きさ(例:10^18 wei)になり得ます。JavaScript の Number 型ではこれらを正確に表現できません。すべての amount フィールドは文字列(TEXT)として保存すべきです。

5. 落とし穴の原因

5.1

WebSocket は HTTP とは異なり、長時間持続する接続です。ネットワークのジッターによって接続が切断される可能性があります。viem の watchEvent は内部的に再接続を試みますが、カスタムインデクサーでは再接続ロジックを明示的に処理すべきです。

5.2

Ethereum のコンセンサスメカニズムは最大約64ブロックの再編成を許容します。保守的な戦略は、最新12ブロックのデータを決してインデックスせず、すべてのインデックス済みデータが「safe head」に達していることを保証することです。

5.3

イベント処理中にクラッシュが発生した場合(例:データベース書き込み失敗)、処理済みのイベントが部分的に書き込まれる可能性があります。トランザクション(db.transaction)を使用して原子性を確保します。

6. 落とし穴の解決方法

  • better-sqlite3 を WAL モードで使用する(書き込みが高速、並行読み取りをサポート)
  • 最後にインデックスしたブロック番号を indexer_state テーブルに記録し、再起動後にそこから再開する
  • 各インデックス実行前に最新12ブロックのデータをクリーンアップする(保守的な reorg 対策)
  • すべての数値フィールドを TEXT 型で保存する(精度低下を防ぐ)
  • バッチ書き込みを db.transaction() でラップして原子性を確保する

7. 技術的ポイント

ポイント説明
イベントリスニングcreatePublicClient + getLogs または watchEvent
チェックポイント再開indexer_state テーブルが最後のブロック番号を記録
再編成対策最新ブロックから12ブロック遅れたデータのみをインデックス
重複排除UNIQUE(tx_hash, log_index) + INSERT OR IGNORE
大きな数値の保存すべての amount フィールドを TEXT(文字列)として保存
バッチ書き込みSQLite トランザクションラッパー、2000ブロックのバッチ
REST APIExpress + SQLite クエリ、ページネーションとフィルタリング付き

Built with AiAda