Appearance
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 API | Express + SQLite クエリ、ページネーションとフィルタリング付き |