Free

Claude に自分の「23s ago」案を論破してもらった

「オンチェーンデータに 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 のおかげで気づきました:もとの直感案は実は製品上の嘘だったということに。


第一直感:とりあえず「23s ago」と貼る

最初に思いついたのはこれ:

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 と表示。

その瞬間に気づきました:「鮮度」を一つの数字にまとめると、その数字自体が物理的に間違っている

3 つの独立した次元

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 + age を並列、フィールドごとに分類

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 扱い。「変わるかも」と過剰にマークするほうが、可変な値を黙って静的に見せるよりまし

実装の詳細:block number はほぼ無料

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 に反対側で議論させる

Claude と 1 年以上仕事をしてきた最大の実感は、コードを書くスピードではなく、こちらが立ち止まって尋ねさえすれば、自分の案を真剣に反駁してくれることです。

「23s ago ラベルを足す」と提案したとき、私自身は間違いに気づいていなかった。自分で PR をレビューしたら、たぶん通していた——「いかにも正しそう」に見えるから。

でも口調を変えて Claude に「この 23s は何から何を引いた値で、どの遅延区間に対応する?」と聞くと、因果連鎖を分解して、その数字の物理的意味が間違っていることを見せてくれた。

このコストは「Claude がコードを多く書いてくれた」ではなく、「製品上の嘘を本番に出さずに済んだ」ことです。

23s ago が本当にリリースされても、誰も文句は言わない——それっぽく見えるし、気づける人はわざわざ訂正してこない。でも分かる人の目には、あなたの DeFi ドキュメントサイトに穴が開いている:このサイトは自分が何を表示しているのか分かっていない

次に Claude と製品判断をするとき、5 分だけこのステップを入れてください:

「いま提案した案について、誤りうるパターンを 3 つ挙げて。それぞれ具体的なシナリオで。」

Claude が想定外を 1 つ見つけたら、その 5 分は元が取れたということです。