「オンチェーンデータに 23s ago ラベル」と提案したら、Claude にそれは製品上の嘘だと指摘された。
smarts.md はスマートコントラクトの live docs サイトです。各コントラクトページには、オンチェーンから直接読み出した値が並びます——Uniswap プールの liquidity、ERC-20 の totalSupply、カスタムコントラクトの owner。これらは最大 60 秒前のスナップショットになり得ます(Solid Cache TTL = 60s)。
ところがページ上には「いつ読んだか」を示す表示がまったくありません。Mintlify のように見えて、静的な文書と区別がつかない。
Claude と一緒にデータ更新戦略を監査していたとき、6 つのギャップを洗い出しました。ギャップ 4 はたった一行:
live データに鮮度表示なし——「as of block X / Y seconds ago」ラベルを足す。
書き終えたあと昼食に出ました。戻ってきて見直し、その一行で 1 時間止まりました。最終的に出した方向は最初の直感とまったく違うもので——しかも、Claude のおかげで気づきました:もとの直感案は実は製品上の嘘だったということに。
最初に思いついたのはこれ:
liquidity: 1,234,567 USDC ← 23s ago
totalSupply: 27.3B USDC ← 12s ago
シンプル、直接的、ユーザーは一瞬で理解できる。
昼食後にもう一度見て、Claude に質問を投げ始めました:
この「23s」は、何から何を引いた値?
Claude は明確に答えました:now - cache.fetched_at。つまり、キャッシュに書き込んでから経過した時間。
じゃあ、チェーン側からの実際の遅れはどのくらい?
Claude は少し考えて言いました:あなたの Multicall は view 関数をまとめて読んでキャッシュに書き、キャッシュは 60 秒間有効。でも、キャッシュ書き込みの瞬間に読んだ block 自体、すでに過去のもの——Ethereum のブロックタイム下限は 0–12s。
つまり実際の因果連鎖はこうです:
チェーンの最新 block 生成
└→ RPC で block N を読む(チェーン頭から 0–12s 遅れ)
└→ Solid Cache に書き込み
└→ ユーザーの画面で描画(キャッシュ最大 60s)
「23s ago」ラベルの 23s は、どの区間に対応する?
最後の区間だけ:now - fetched_at、キャッシュ書き込みから今までの時間。
前段のチェーン側の遅れはまったく反映されていない。
具体例:ユーザーが「fetched 5s ago」を見るが、その block 自体すでに 12s 前のもので、いまチェーン頭はもう 2 ブロック進んでいるかもしれない——実際のチェーン遅延は約 24s、ラベルは 5s と表示。
その瞬間に気づきました:「鮮度」を一つの数字にまとめると、その数字自体が物理的に間違っている。
Claude にこれを整理してもらった結果——3 つの独立した次元があります:
| 次元 | 意味 | 物理的下限 |
|---|---|---|
| ブロック鮮度 | チェーン頭 → 我々が読んだ block | Ethereum ブロックタイム 0–12s |
| 読み取り鮮度 | RPC 読み取り → cache 書き込み | 0(同期) |
| 表示鮮度 | cache 書き込み → ユーザー画面 | 0–60s(cache TTL) |
これらを足して「freshness: 35s」と表示するのは物理的に存在しません。chain → cache → screen の因果連鎖は同期していない:cache は書き込まれた瞬間に凍結するが、チェーンはブロックを生成し続ける。これは精度の問題ではなく、カテゴリの間違いです。
さらに重要なのは:この 3 区間のどれが重要かは、フィールド自体に依存する。
decimals:コンストラクタで定義され、二度と変わらない。鮮度表示はまったく不要。owner:ガバナンスでしか変わらず、何年も動かないかも。どの block で読んだかは知りたい(「5 回のアップグレード前」を見ていないか確認)が、「23 秒前」は不要。liquidity / slot0:ブロックごとに変わり得る。block も時間も両方重要。block number は唯一の客観的アンカー——チェーン上の事実であり、こちらのキャッシュ戦略で歪まない。
最終的な表示:
liquidity: 1,234,567 USDC
↳ as of Block #19,234,567 · 23s ago
block は分かる人向け、「23s ago」は分からない人向け。どちらも嘘をつかない:block は物理的事実、「23s ago」は明確にキャッシュ書き込みから現在までの時間で、「チェーン頭からの遅れ」ではない。
フィールド属性で表示を分けます:
# app/services/chain_reader/field_mutability.rb
module ChainReader
module FieldMutability
IMMUTABLE = %w[
name symbol decimals
DOMAIN_SEPARATOR PERMIT_TYPEHASH
factory token0 token1 fee tickSpacing
asset underlying
].freeze
SLOW = %w[
owner
paused deprecated
pauser blacklister masterMinter rescuer
upgradedAddress implementation
maxTotalSupply
].freeze
def self.classify(function_name)
return :immutable if IMMUTABLE.include?(function_name)
return :slow if SLOW.include?(function_name)
:fast
end
end
end
描画ルール:
| 分類 | 例 | 表示 |
|---|---|---|
| immutable | decimals, name, symbol |
鮮度表示なし(永遠に変わらない、ノイズ) |
| slow | owner, paused |
block のみ、秒数なし |
| fast | slot0, liquidity, totalSupply |
block + 秒数 |
このホワイトリストは意図的に保守的——リスト外はすべて fast 扱い。「変わるかも」と過剰にマークするほうが、可変な値を黙って静的に見せるよりまし。
Multicall3 自体に getBlockNumber() 関数があります。各バッチの末尾に追加するだけ——RPC は 1 回、値が 1 つ増えるだけで、遅延はほぼ増えません:
# app/services/chain_reader/multicall3_client.rb
BLOCK_NUMBER_FN = {
"name" => "getBlockNumber",
"inputs" => [],
"outputs" => [{ "type" => "uint256" }]
}.freeze
def call(calls)
block_call = Call.new(target: ADDRESS, function: BLOCK_NUMBER_FN)
augmented = calls + [block_call]
# ...aggregate3 で一発実行
Batch.new(block_number: block_number, results: results)
end
ViewCaller のキャッシュ payload もバージョンアップ(CACHE_VERSION = "v2")。{fn_sig => result} から Snapshot(results:, block_number:, fetched_at:) へ。Snapshot は def [](key) = @results[key] などの委譲メソッドで旧 hash インターフェースと互換性を保ち、既存の呼び出し側は変更不要。
UI ヘルパーはこんな感じ:
def freshness_tag_for(fn)
return nil unless @live_snapshot&.block_number
mutability = ChainReader::FieldMutability.classify(fn["name"])
return nil if mutability == :immutable
block_text = "Block ##{number_with_delimiter(@live_snapshot.block_number)}"
label =
if mutability == :fast && @live_snapshot.fetched_at
"as of #{block_text} · #{freshness_phrase(@live_snapshot.fetched_at)}"
else
"as of #{block_text}"
end
# ...
end
PR 全体:24 ファイル、+676 / -55。
実装後、差別化ポイントを書き直しました:
Smarts は フィールドごとに、その状態がどの block で読まれたかを明示的に教えてくれる 最初の DeFi ドキュメントです。
これは「Mintlify よりフレッシュ」より具体的で、信頼性の境界が明確。実遅延を隠さずに晒す——CLAUDE.md に書いた 2 つの原則「AI は道具で展示品ではない」+「Build in Public」と一致しています。
Claude と 1 年以上仕事をしてきた最大の実感は、コードを書くスピードではなく、こちらが立ち止まって尋ねさえすれば、自分の案を真剣に反駁してくれることです。
「23s ago ラベルを足す」と提案したとき、私自身は間違いに気づいていなかった。自分で PR をレビューしたら、たぶん通していた——「いかにも正しそう」に見えるから。
でも口調を変えて Claude に「この 23s は何から何を引いた値で、どの遅延区間に対応する?」と聞くと、因果連鎖を分解して、その数字の物理的意味が間違っていることを見せてくれた。
このコストは「Claude がコードを多く書いてくれた」ではなく、「製品上の嘘を本番に出さずに済んだ」ことです。
23s ago が本当にリリースされても、誰も文句は言わない——それっぽく見えるし、気づける人はわざわざ訂正してこない。でも分かる人の目には、あなたの DeFi ドキュメントサイトに穴が開いている:このサイトは自分が何を表示しているのか分かっていない。
次に Claude と製品判断をするとき、5 分だけこのステップを入れてください:
「いま提案した案について、誤りうるパターンを 3 つ挙げて。それぞれ具体的なシナリオで。」
Claude が想定外を 1 つ見つけたら、その 5 分は元が取れたということです。