本番環境でのオンチェーンデータ:誰も教えてくれないこと
ブロックチェーンデータはクリーンでも、信頼できるものでも、簡単なものでもない。RPCレート制限、チェーンリオーグ、BigIntのバグ、インデクシングのトレードオフ — 実際のDeFi製品をリリースして得た厳しい教訓。
オンチェーンデータは本質的に信頼できるという幻想がある。不変の台帳。透明な状態。読み取るだけで完了。
私もそう信じていた。そして DeFi ダッシュボードを本番環境にリリースし、トークン残高がなぜ間違っているのか、イベント履歴にギャップがあるのか、データベースにもう存在しないブロックからのトランザクションが含まれているのかを解明するのに3週間を費やした。
オンチェーンデータは生で、敵対的で、ユーザーがバグ報告を提出するまで気づかない形でアプリケーションを壊すエッジケースに満ちている。この記事では、痛い目に遭って学んだすべてを扱う。
信頼できるデータという幻想#
まず誰も教えてくれないこと:ブロックチェーンはデータを提供しない。状態遷移を提供する。SELECT * FROM transfers WHERE user = '0x...' は存在しない。あるのはログ、レシート、ストレージスロット、コールトレースで、すべてデコードにはコンテキストが必要なフォーマットでエンコードされている。
Transfer イベントログは from、to、value を提供する。トークンシンボルは教えてくれない。デシマルも教えてくれない。これが正当な送金なのか、それとも fee-on-transfer トークンが3%を抜き取っているのかも教えてくれない。このブロックが30秒後にまだ存在するかどうかも教えてくれない。
「不変」の部分は正しい — ファイナライズ後は。しかしファイナライゼーションは即座ではない。そして RPC ノードから返されるデータは必ずしもファイナライズされたブロックからのものではない。ほとんどの開発者は latest をクエリしてそれを真実として扱う。それはフィーチャーではなくバグだ。
そしてエンコーディングの問題がある。すべてが16進数だ。アドレスは大文字小文字混在のチェックサム付き(またはそうでない)。トークンの量は 10^decimals で掛けた整数だ。100ドルのUSDC送金はオンチェーンでは 100000000 に見える。USDCのデシマルが18ではなく6だからだ。すべてのERC-20トークンに18デシマルを仮定した本番コードを見たことがある。結果の残高は10^12倍ずれていた。
RPCレート制限が週末を台無しにする#
すべての本番Web3アプリはRPCエンドポイントと通信する。そして、すべてのRPCエンドポイントには想像以上に厳しいレート制限がある。
重要な数字:
- Alchemy Free: 月あたり約3000万コンピュートユニット、40リクエスト/分。広いブロック範囲に対する単一の
eth_getLogs呼び出しが数百CUを消費しうることに気づくまでは潤沢に聞こえる。1日のインデクシングで月間クォータを使い果たす。 - Infura Free: 1日10万リクエスト、おおよそ1.15リクエスト/秒。そのレートで50万ブロックのイベントログをページネーションしてみてほしい。
- QuickNode Free: Infura と同様 — 1日10万リクエスト。
有料ティアは助けになるが、問題を解消するわけではない。Alchemy の Growth プランで月200ドル払っても、重いインデクシングジョブはスループット制限に達する。そして制限に達したとき、グレースフルなデグラデーションは起きない。429エラーが発生し、時には不親切なメッセージと共に、時には retry-after ヘッダーなしで。
解決策はフォールバックプロバイダ、リトライロジック、そしてどの呼び出しを行うかについて非常に慎重になることの組み合わせだ。viem を使った堅牢な RPC セットアップはこのようになる:
import { createPublicClient, fallback, http } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: fallback(
[
http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
retryCount: 3,
retryDelay: 1500,
timeout: 15_000,
}),
http("https://mainnet.infura.io/v3/YOUR_KEY", {
retryCount: 3,
retryDelay: 1500,
timeout: 15_000,
}),
http("https://rpc.ankr.com/eth", {
retryCount: 2,
retryDelay: 2000,
timeout: 20_000,
}),
],
{ rank: true }
),
});rank: true オプションが重要だ。viem に各トランスポートのレイテンシと成功率を測定させ、最も速くて信頼性の高いものを自動的に優先させる。Alchemy がレート制限を始めたら、viem はトラフィックを Infura に移す。Infura がダウンしたら、Ankr にフォールバックする。
ただし微妙な点がある:viem のデフォルトのリトライロジックは指数バックオフを使用し、通常それが望ましい。しかし、2025年初頭の時点で、バッチモードが有効な場合に retryCount がRPCレベルのエラー(429など)を適切にリトライしないという既知の問題がある。リクエストをバッチ処理している場合は、リトライの動作を明示的にテストすること。動作を信頼してはいけない。
リオーグ:見えないバグ#
チェーンの再編成(リオーグ)は、ネットワークがどのブロックが正規かについて一時的に意見が分かれるときに発生する。ノードAはトランザクション[A, B, C]を持つブロック1000を見る。ノードBはトランザクション[A, D]を持つ異なるブロック1000を見る。最終的にネットワークは収束し、一方のバージョンが勝つ。
プルーフ・オブ・ワークのチェーンでは、これは一般的だった — 1〜3ブロックのリオーグは1日に何度も発生した。マージ後の Ethereum はより良い。成功するリオーグ攻撃には現在、バリデーターの約50%の協調が必要だ。しかし「より良い」は「不可能」ではない。2022年5月にビーコンチェーンで注目すべき7ブロックのリオーグがあった。これはプロポーザーブーストフォークのクライアント実装の不一致が原因だった。
そして Ethereum メインネットでリオーグがどれほどまれかは関係ない。L2やサイドチェーン、Polygon、Arbitrum、Optimism で構築しているなら、リオーグはより頻繁だ。Polygon では歴史的に10ブロック以上のリオーグがあった。
実際の問題はこうだ:ブロック18,000,000をインデックスした。データベースにイベントを書き込んだ。そしてブロック18,000,000がリオーグされた。今、データベースには正規チェーン上に存在しないブロックからのイベントがある。それらのイベントは実際には起きなかったトランザクションを参照しているかもしれない。ユーザーは幻の送金を見る。
修正はアーキテクチャに依存する:
オプション1:確認遅延。 N ブロックの確認が経過するまでデータをインデックスしない。Ethereum メインネットでは、64ブロック(2エポック)でファイナリティが保証される。L2については、特定のチェーンのファイナリティモデルを確認すること。これはシンプルだがレイテンシが加わる — Ethereum で約13分。
オプション2:リオーグ検出とロールバック。 積極的にインデックスするがブロックハッシュを追跡する。新しいブロックごとに、親ハッシュが前にインデックスしたブロックと一致するか検証する。一致しなければ、リオーグを検出したことになる。孤立ブロックからのすべてを削除し、正規チェーンを再インデックスする。
interface IndexedBlock {
number: bigint;
hash: `0x${string}`;
parentHash: `0x${string}`;
}
async function detectReorg(
client: PublicClient,
lastIndexed: IndexedBlock
): Promise<{ reorged: boolean; depth: number }> {
const currentBlock = await client.getBlock({
blockNumber: lastIndexed.number,
});
if (currentBlock.hash === lastIndexed.hash) {
return { reorged: false, depth: 0 };
}
// チェーンが分岐した地点を見つけるため後方に走査する
let depth = 1;
let checkNumber = lastIndexed.number - 1n;
while (checkNumber > 0n && depth < 128) {
const onChain = await client.getBlock({ blockNumber: checkNumber });
const inDb = await getIndexedBlock(checkNumber); // DBルックアップ
if (onChain.hash === inDb?.hash) {
return { reorged: true, depth };
}
depth++;
checkNumber--;
}
return { reorged: true, depth };
}これは仮定の話ではない。リオーグ検出なしでチェーンのティップでイベントをインデックスしていた本番システムがあった。3週間は問題なく動いた。その後、Polygon での2ブロックのリオーグがデータベースに重複したNFTミントイベントを引き起こした。フロントエンドはユーザーが所有していないトークンを所有しているように表示した。リオーグが根本原因だと誰も疑わなかったため、デバッグに2日かかった。
インデクシング問題:痛みを選べ#
構造化されたオンチェーンデータをアプリケーションに取り込むには、3つの現実的な選択肢がある。
直接 RPC 呼び出し#
getLogs、getBlock、getTransaction を直接呼び出すだけだ。これは小規模な読み取りには機能する — ユーザーの残高チェック、単一コントラクトの最近のイベントフェッチ。過去のインデクシングやコントラクトをまたぐ複雑なクエリには機能しない。
問題は組み合わせ爆発だ。過去30日間のすべてのUniswap V3スワップが欲しい? それは約20万ブロックだ。Alchemy の getLogs 呼び出しあたり2Kブロック範囲制限では、最低100回のページネーションリクエストになる。各リクエストがレート制限にカウントされる。そしてどれかの呼び出しが失敗したら、リトライロジック、カーソル追跡、中断した場所から再開する方法が必要だ。
The Graph(サブグラフ)#
The Graph は元祖のソリューションだった。スキーマを定義し、AssemblyScript でマッピングを書き、デプロイし、GraphQL でクエリする。ホステッドサービスは廃止され、現在はすべて分散型 Graph Network 上にあり、クエリに GRT トークンで支払う。
良い点:標準化されていて、ドキュメントが充実しており、フォークできる既存のサブグラフの大規模なエコシステムがある。
悪い点:AssemblyScript は辛い。デバッグは限られる。デプロイに数分から数時間かかる。サブグラフにバグがあれば、再デプロイしてゼロから再同期を待つ。分散型ネットワークはレイテンシを追加し、インデクサーがチェーンのティップに遅れることもある。
30〜60秒のデータ鮮度が許容される読み取り重視のダッシュボードで The Graph を使ってきた。そこではうまく機能する。リアルタイムデータやマッピング内の複雑なビジネスロジックが必要なものには使わない。
カスタムインデクサー(Ponder、Envio)#
ここがエコシステムが大幅に成熟した領域だ。Ponder と Envio では、TypeScript でインデクシングロジックを書き(AssemblyScript ではなく)、開発中にローカルで実行し、スタンドアロンサービスとしてデプロイできる。
Ponder は最大限の制御を提供する。TypeScript でイベントハンドラを定義し、インデクシングパイプラインを管理し、SQLデータベースを出力として得る。トレードオフ:インフラは自分で所有する。スケーリング、モニタリング、リオーグ処理は自分の責任だ。
Envio は同期速度に最適化されている。彼らのベンチマークでは、The Graph と比較して大幅に速い初期同期時間を示している。リオーグをネイティブに処理し、より高速なデータフェッチのための専用プロトコル HyperSync をサポートしている。トレードオフ:彼らのインフラと API に依存することになる。
私の推奨:本番の DeFi アプリを構築しており、エンジニアリング能力があるなら、Ponder を使う。最速の同期が必要でインフラを管理したくないなら、Envio を評価する。クイックプロトタイプやコミュニティが保守するサブグラフが必要なら、The Graph でもまだ問題ない。
getLogs は見た目以上に危険だ#
eth_getLogs RPC メソッドは欺くほどシンプルだ。ブロック範囲とフィルターを渡すと、一致するイベントログが返される。本番環境では実際にはこうなる:
ブロック範囲制限はプロバイダによって異なる。 Alchemy は2Kブロック(ログ無制限)または無制限ブロック(最大10Kログ)で上限がある。Infura には異なる制限がある。QuickNode には異なる制限がある。パブリック RPC は1Kブロックで上限がある場合もある。コードはこれらすべてを処理する必要がある。
レスポンスサイズ制限が存在する。 ブロック範囲内であっても、人気のあるコントラクトがブロックあたり数千のイベントを発行する場合、レスポンスがプロバイダのペイロード制限(Alchemy では150MB)を超える可能性がある。呼び出しは部分的な結果を返さない。失敗する。
空の範囲も無料ではない。 一致するログがゼロであっても、プロバイダはブロック範囲をスキャンする。これはコンピュートユニットにカウントされる。
これらの制約を処理するページネーションユーティリティがこちらだ:
import type { PublicClient, Log, AbiEvent } from "viem";
async function fetchLogsInChunks<T extends AbiEvent>(
client: PublicClient,
params: {
address: `0x${string}`;
event: T;
fromBlock: bigint;
toBlock: bigint;
maxBlockRange?: bigint;
}
): Promise<Log<bigint, number, false, T, true>[]> {
const { address, event, fromBlock, toBlock, maxBlockRange = 2000n } = params;
const allLogs: Log<bigint, number, false, T, true>[] = [];
let currentFrom = fromBlock;
while (currentFrom <= toBlock) {
const currentTo =
currentFrom + maxBlockRange - 1n > toBlock
? toBlock
: currentFrom + maxBlockRange - 1n;
try {
const logs = await client.getLogs({
address,
event,
fromBlock: currentFrom,
toBlock: currentTo,
});
allLogs.push(...logs);
currentFrom = currentTo + 1n;
} catch (error) {
// 範囲が大きすぎる場合(結果が多すぎる場合)、半分に分割する
if (isRangeTooLargeError(error) && currentTo > currentFrom) {
const mid = currentFrom + (currentTo - currentFrom) / 2n;
const firstHalf = await fetchLogsInChunks(client, {
address,
event,
fromBlock: currentFrom,
toBlock: mid,
maxBlockRange,
});
const secondHalf = await fetchLogsInChunks(client, {
address,
event,
fromBlock: mid + 1n,
toBlock: currentTo,
maxBlockRange,
});
allLogs.push(...firstHalf, ...secondHalf);
currentFrom = currentTo + 1n;
} else {
throw error;
}
}
}
return allLogs;
}
function isRangeTooLargeError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("Log response size exceeded") ||
message.includes("query returned more than") ||
message.includes("exceed maximum block range")
);
}重要な洞察は、失敗時のバイナリ分割だ。2Kブロック範囲が多すぎるログを返したら、2つの1K範囲に分割する。1Kでもまだ多すぎたら、さらに分割する。イベント密度を事前に知る必要なく、高活性なコントラクトに自動的に適応する。
BigInt はあなたを謙虚にさせる#
JavaScript の Number 型は64ビット浮動小数点だ。2^53 - 1 まで、約9000兆の整数を表現できる。大きく聞こえるが、wei での1 ETHのトークン量は 1000000000000000000、ゼロが18個の数字だ。それは 10^18 で、Number.MAX_SAFE_INTEGER をはるかに超えている。
パイプラインのどこかで BigInt を Number に変換してしまったら、JSON.parse で、データベースドライバで、ロギングライブラリで、精度の静かな損失が起きる。数値はおおよそ正しく見えるが、最後の数桁が間違っている。テストの金額が小さいため、テストではこれを捕捉できない。
本番環境にリリースしたバグがこれだ:
// バグ:無害に見えるが、そうではない
function formatTokenAmount(amount: bigint, decimals: number): string {
return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
// 小さな金額では正しく動作する:
formatTokenAmount(1000000n, 6); // "1.0000" -- 正しい
// 大きな金額では静かに壊れる:
formatTokenAmount(123456789012345678n, 18);
// "0.1235" を返す -- 間違い、実際の精度が失われている
// Number(123456789012345678n) === 123456789012345680
// IEEE 754 により最後の2桁が丸められた修正:除算前に Number に変換しないこと。文字列と BigInt で操作する viem の組み込みユーティリティを使う:
import { formatUnits, parseUnits } from "viem";
// 正しい:BigInt で操作し、文字列を返す
function formatTokenAmount(
amount: bigint,
decimals: number,
displayDecimals: number = 4
): string {
const formatted = formatUnits(amount, decimals);
// formatUnits は "0.123456789012345678" のような全精度文字列を返す
// 目的の表示精度に切り捨てる(丸めない)
const [whole, fraction = ""] = formatted.split(".");
const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
return `${whole}.${truncated}`;
}
// これも重要:ユーザー入力には parseFloat ではなく parseUnits を使う
function parseTokenInput(input: string, decimals: number): bigint {
// parseUnits が文字列から BigInt への変換を正しく処理する
return parseUnits(input, decimals);
}丸めではなく切り捨てにしていることに注目してほしい。これは意図的だ。金融のコンテキストでは、実際の値が "1.00009999..." のときに "1.0001 ETH" と表示する方が、実際の値が "1.00005001..." で切り上げられて "1.0001" と表示するよりも良い。ユーザーは表示された金額に基づいて意思決定する。切り捨ては保守的な選択だ。
もう一つの罠:JSON.stringify は BigInt をシリアライズする方法を知らない。スローする。トークン量を含む API からのすべてのレスポンスにシリアライゼーション戦略が必要だ。API の境界で文字列変換を使用している:
// API レスポンスシリアライザ
function serializeForApi(data: Record<string, unknown>): string {
return JSON.stringify(data, (_, value) =>
typeof value === "bigint" ? value.toString() : value
);
}キャッシュ戦略:何を、どのくらい、いつ無効化するか#
すべてのオンチェーンデータが同じ鮮度要件を持つわけではない。私が使っている階層がこれだ:
永久にキャッシュ(不変):
- トランザクションレシート(マイニングされたら変わらない)
- ファイナライズされたブロックデータ(ブロックハッシュ、タイムスタンプ、トランザクションリスト)
- コントラクトバイトコード
- ファイナライズされたブロックからの過去のイベントログ
数分から数時間キャッシュ:
- トークンメタデータ(名前、シンボル、デシマル)— 技術的にはほとんどのトークンで不変だが、プロキシアップグレードで実装が変わる可能性がある
- ENS 解決 — 5分のTTLがうまく機能する
- トークン価格 — 精度要件による、30秒から5分
数秒キャッシュまたはキャッシュなし:
- 現在のブロック番号
- アカウント残高とnonce
- 保留中のトランザクション状態
- 未ファイナライズのイベントログ(再びリオーグの問題)
実装が複雑である必要はない。インメモリLRUとRedisの2層キャッシュでほとんどのケースをカバーする:
import { LRUCache } from "lru-cache";
const memoryCache = new LRUCache<string, unknown>({
max: 10_000,
ttl: 1000 * 60, // デフォルト1分
});
type CacheTier = "immutable" | "short" | "volatile";
const TTL_MAP: Record<CacheTier, number> = {
immutable: 1000 * 60 * 60 * 24, // メモリ内24時間、Redis では永久
short: 1000 * 60 * 5, // 5分
volatile: 1000 * 15, // 15秒
};
async function cachedRpcCall<T>(
key: string,
tier: CacheTier,
fetcher: () => Promise<T>
): Promise<T> {
// まずメモリをチェック
const cached = memoryCache.get(key) as T | undefined;
if (cached !== undefined) return cached;
// 次に Redis(あれば)
// const redisCached = await redis.get(key);
// if (redisCached) { ... }
const result = await fetcher();
memoryCache.set(key, result, { ttl: TTL_MAP[tier] });
return result;
}
// 使用例:
const receipt = await cachedRpcCall(
`receipt:${txHash}`,
"immutable",
() => client.getTransactionReceipt({ hash: txHash })
);直感に反する教訓:最大のパフォーマンス向上は RPC レスポンスのキャッシュではない。RPC 呼び出しを完全に避けることだ。getBlock を呼び出そうとするたびに自問してほしい:今チェーンからのデータが本当に必要なのか、それとも既に持っているデータから導出できるのか? ポーリングの代わりに WebSocket でイベントをリッスンできないか? 複数の読み取りを単一の multicall にバッチ処理できないか?
TypeScript とコントラクト ABI:正しいやり方#
viem の型システムは ABIType を搭載し、コントラクト ABI から TypeScript コードまでの完全なエンドツーエンドの型推論を提供する。ただし、正しくセットアップした場合に限る。
間違ったやり方:
// 型推論なし — args は unknown[]、戻り値は unknown
const result = await client.readContract({
address: "0x...",
abi: JSON.parse(abiString), // ランタイムでパース = 型情報なし
functionName: "balanceOf",
args: ["0x..."],
});正しいやり方:
// 完全な型推論のために ABI を const として定義する
const erc20Abi = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "balance", type: "uint256" }],
},
{
name: "transfer",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "success", type: "bool" }],
},
] as const;
// これで TypeScript は以下を知っている:
// - functionName は "balanceOf" | "transfer" にオートコンプリートされる
// - balanceOf の args は [address: `0x${string}`]
// - balanceOf の戻り値の型は bigint
const balance = await client.readContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: erc20Abi,
functionName: "balanceOf",
args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- 完全に型付けされているas const アサーションが機能させるものだ。これがなければ、TypeScript は ABI の型を { name: string, type: string, ... }[] に拡張し、推論の仕組みすべてが崩壊する。これは Web3 TypeScript コードベースで最もよく見る間違いだ。
より大きなプロジェクトでは、@wagmi/cli を使って Foundry や Hardhat プロジェクトから型付きコントラクトバインディングを直接生成する。コンパイル済みの ABI を読み込み、as const アサーションが既に適用された TypeScript ファイルを生成する。手動での ABI コピー不要、型のずれ不要。
不都合な真実#
ブロックチェーンデータは、データベース問題を装った分散システム問題だ。「ただの別の API」として扱った瞬間、開発環境では見えず、本番環境で断続的に発生するバグが蓄積し始める。
ツーリングは劇的に良くなった。viem は型安全性と開発者体験において ethers.js からの大幅な改善だ。Ponder と Envio はカスタムインデクシングを手の届くものにした。しかし根本的な課題、リオーグ、レート制限、エンコーディング、ファイナリティはプロトコルレベルだ。ライブラリがそれらを抽象化してくれることはない。
RPC が嘘をつき、ブロックが再編成され、数値がオーバーフローし、キャッシュが古いデータを提供するという前提で構築すること。そして各ケースを明示的に処理すること。
それが本番環境グレードのオンチェーンデータのあるべき姿だ。