我提了「鏈上資料加個 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 幫我意識到,原來那個直覺方案本身是產品撒謊。
我第一反應是這樣的:
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 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() 函式。把它加到每次 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 共事一年多最大的感受不是它寫程式多快,而是它會很認真地反駁我自己的方案——只要你願意停下來問。
我自己提「加個 23s ago 標籤」的時候沒意識到這是錯的。如果讓我自己 review 一遍 PR,我大概率也通過了——它看起來太「理所當然」。
但當我換個語氣問 Claude「這個 23s 到底是誰減誰、對應哪段延遲」,它就把因果鏈拆出來,讓我看到那個數字的物理意義其實是錯的。
這件事的代價不是「Claude 幫我多寫了點程式」,是它幫我沒把一個產品謊言上線。
如果 23s ago 真的上線了,沒人會投訴——它看著合理,懂的人懶得糾正你。但你的 DeFi 文件站在懂行的人眼裡就有了一個洞:這站不知道自己顯示的是什麼。
下次你和 Claude 一起做產品決策時,留 5 分鐘做這一步:
「我剛才提的方案,請你列出三種可能錯的方式,每種給一個具體場景。」
如果 Claude 找出來一種你之前沒想到的,那 5 分鐘就值了。