免费

让 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 分钟就值了。