我提了"链上数据加个 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 分钟就值了。