免費

讓 Claude 戳破我自己提的 "23s ago"

我提了「鏈上資料加個 23s ago 標籤」,Claude 幫我看出這是產品撒謊。


smarts.md 是個智慧合約 live docs 站。每個合約頁面有一組即時從鏈上讀出來的數值——Uniswap pool 的 liquidity、ERC-20 的 totalSupply、自訂合約的 owner。這些值最長可能是 60 秒前的快照(Solid Cache TTL = 60s)。

但頁面上完全沒有任何「何時讀的」提示。看上去像 Mintlify,跟一個靜態文件沒區別。

我和 Claude 一起做資料更新策略稽核時,列了六個缺口。缺口 4 一句話帶過:

live 資料無新鮮度提示——加個 "as of block X / Y seconds ago" 標籤。

寫完待辦我去吃了頓飯。回來重新看,停在這句話上一個小時。最後改的方向跟原來想的完全不同——而且讓 Claude 幫我意識到,原來那個直覺方案本身是產品撒謊


第一直覺:加個 "23s ago"

我第一反應是這樣的:

liquidity: 1,234,567 USDC   ← 23s ago
totalSupply: 27.3B USDC     ← 12s ago

簡單、直接、使用者秒懂。

吃完飯回來再看一眼,我開始問 Claude 幾個問題:

這個 "23s" 到底是誰減誰?

它列出來:now - cache.fetched_at。也就是說,是快取寫入之後過了多久。

那鏈端真實落後多少?

Claude 想了一下,說:你的 Multicall 一次讀完所有 view 函數,然後寫進快取,快取命中持續 60 秒。但快取寫入那一刻讀到的 block,本身就已經是過去出的——Ethereum 出塊 0–12s 的物理下限。

所以真正的因果鏈是:

鏈端最新 block 出塊
  └→ 我們 RPC 讀到 block(落後 0–12s)
       └→ 寫進 Solid Cache
            └→ 使用者螢幕渲染(快取最長保持 60s)

那 "23s ago" 標籤裡的 23s,對應的是哪一段?

只對應最後一段:now - fetched_at,快取寫入到現在多久。

前面那段鏈上落後完全沒體現

舉個具體例子:使用者看到 "fetched 5s ago",但那個 block 本身已經是 12s 前出的,此刻鏈端可能又走了 2 個塊——真實鏈端落後約 24s,標籤顯示卻是 5s。

那一刻我意識到:把「新鮮度」寫成單一數字,這數字本身在物理上就是錯的。

三個維度,不能合併

讓 Claude 幫我把這件事寫清楚——結果是三個獨立維度:

維度 含義 物理下限
區塊新鮮度 鏈端最新 block → 我們讀到的 block Ethereum 出塊 0–12s
讀取新鮮度 RPC 讀到 → cache 寫入 0(同步)
展示新鮮度 cache 寫入 → 使用者螢幕 0–60s(cache TTL)

把這三件事 sum 成一個 "freshness: 35s" 在物理上不存在。chain → cache → screen 三段的因果鏈不是同步的:cache 一旦寫入就凍結,但鏈端繼續出塊。這不是工程精度問題,是範疇錯誤。

更關鍵的是:這三段哪一段重要,取決於欄位本身

  • decimals:建構時定義,永不再變。完全不需要新鮮度。
  • owner:governance 改才會變,可能幾年不動。需要知道是哪個 block 讀的(保證你看的不是「五次升級前」),但不需要「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() 函式。把它加到每次 batch 的末尾——一次 RPC,多讀一個值,幾乎不增加延遲:

# 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 helper 長這樣:

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 是第一個 DeFi 文件明確告訴你每個欄位的狀態對應哪個 block 的。

這比「我們比 Mintlify 新鮮」更具體、更有可信度邊界。它暴露了真實延遲而不是假裝藏起來——符合 CLAUDE.md 寫過的兩條原則:「AI 是工具不是展示品」+「Build in Public」。

教訓:讓 Claude 做反方辯論

我和 Claude 共事一年多最大的感受不是它寫程式多快,而是它會很認真地反駁我自己的方案——只要你願意停下來問。

我自己提「加個 23s ago 標籤」的時候沒意識到這是錯的。如果讓我自己 review 一遍 PR,我大概率也通過了——它看起來太「理所當然」。

但當我換個語氣問 Claude「這個 23s 到底是誰減誰、對應哪段延遲」,它就把因果鏈拆出來,讓我看到那個數字的物理意義其實是錯的。

這件事的代價不是「Claude 幫我多寫了點程式」,是它幫我沒把一個產品謊言上線

如果 23s ago 真的上線了,沒人會投訴——它看著合理,懂的人懶得糾正你。但你的 DeFi 文件站在懂行的人眼裡就有了一個洞:這站不知道自己顯示的是什麼

下次你和 Claude 一起做產品決策時,留 5 分鐘做這一步:

「我剛才提的方案,請你列出三種可能錯的方式,每種給一個具體場景。」

如果 Claude 找出來一種你之前沒想到的,那 5 分鐘就值了。