Free

How Claude Called BS on My Own "23s ago"

I proposed a "23s ago" label for on-chain data. Claude helped me see it was a product lie.


smarts.md is a live docs site for smart contracts. Every contract page shows a set of values read straight off-chain — Uniswap pool liquidity, ERC-20 totalSupply, custom contract owner. Those values can be up to 60 seconds stale (Solid Cache TTL = 60s).

But the page had zero indication of "when was this read?" It looked like Mintlify — indistinguishable from a static doc.

While auditing my data refresh strategy with Claude, I listed six gaps. Gap 4 was a single line:

live data has no freshness indicator — add an "as of block X / Y seconds ago" label.

I went to lunch. When I came back I sat on that one line for an hour. The direction I ended up shipping was completely different from my first instinct — and Claude helped me realize that the original plan was actually a product lie.


First instinct: just slap "23s ago" on it

My first thought was this:

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

Simple, direct, instantly readable.

After lunch I looked again, and started asking Claude a few questions:

What does the "23s" actually subtract from what?

It spelled it out: now - cache.fetched_at. In other words, time since we wrote the value into the cache.

So how far behind chain head are we, really?

Claude thought about it: your Multicall reads all view functions at once, writes them to cache, and the cache stays valid for 60 seconds. But the block we read at the moment of writing is itself in the past — Ethereum's block time floor is 0–12s.

So the real causal chain is:

chain head produces a new block
  └→ our RPC reads at block N (lag: 0–12s behind head)
       └→ we write to Solid Cache
            └→ user's screen renders (cache valid up to 60s)

So what does the "23s ago" label actually correspond to?

Only the last segment: now - fetched_at, the time from cache write to now.

The chain-side lag isn't reflected at all.

Concrete example: user sees "fetched 5s ago", but that block is itself already 12s old, and chain head may have produced 2 more blocks since — actual chain lag ≈ 24s, label says 5s.

That's when it clicked: collapsing "freshness" into a single number is physically wrong.

Three independent dimensions

Claude helped me write it out clearly — there are three independent dimensions:

Dimension What it measures Physical floor
Block freshness chain head → block we read Ethereum block time 0–12s
Read freshness RPC read → cache write 0 (synchronous)
Display freshness cache write → user screen 0–60s (cache TTL)

Summing these into one "freshness: 35s" number does not exist physically. The chain → cache → screen chain is not synchronous: cache freezes the moment it's written, while the chain keeps producing blocks. This isn't a precision problem, it's a category error.

More importantly: which segment matters depends on the field itself.

  • decimals: defined at construction, never changes. Doesn't need freshness at all.
  • owner: only changes via governance, may stay put for years. You want to know which block it was read at (so you're not seeing it from "five upgrades ago"), but not "23 seconds ago".
  • liquidity / slot0: can change every single block. Both block and time matter.

The fix: block + age side by side, segmented by field type

The block number is the only objective anchor — it's the truth on-chain, untouched by our caching strategy.

What ships:

liquidity: 1,234,567 USDC
↳ as of Block #19,234,567 · 23s ago

Block for the technical reader, "23s ago" for everyone else. Neither one lies: block is a physical fact, "23s ago" specifically means time since cache write, not "lag behind chain head".

Differentiated display by field type:

# 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

Render rules:

Class Example Display
immutable decimals, name, symbol no freshness (would be pure noise)
slow owner, paused block only, no seconds
fast slot0, liquidity, totalSupply block + seconds

The whitelist is deliberately conservative — anything not on the list defaults to fast. Better to over-mark something as live than to silently render a mutable value as static.

Implementation: block number is basically free

Multicall3 itself exposes getBlockNumber(). Append it to every batch — one RPC, one extra value, near-zero overhead:

# 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 in one shot
  Batch.new(block_number: block_number, results: results)
end

The ViewCaller cached payload got a version bump (CACHE_VERSION = "v2"), going from {fn_sig => result} to Snapshot(results:, block_number:, fetched_at:). The Snapshot delegates def [](key) = @results[key] and friends to keep the old hash interface intact — no caller had to change.

The 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

Total PR: 24 files, +676 / -55.

The actual product story

Once it shipped, I rewrote our differentiator:

Smarts is the first DeFi doc that tells you, per field, which block its state was read at.

That's more specific and more credible than "we're fresher than Mintlify". It exposes real latency instead of hiding it — aligned with the two CLAUDE.md principles I keep coming back to: "AI is a tool, not a showpiece" + "Build in Public".

Lesson: have Claude argue against you

After more than a year working with Claude, my biggest takeaway isn't how fast it writes code — it's that it will seriously argue with my own plan if I bother to stop and ask.

When I proposed "add a 23s ago label", I didn't see anything wrong with it. If I'd reviewed my own PR, I probably would have shipped it — it looks too "obviously right".

But when I shifted register and asked Claude "what does that 23s actually subtract from what, and which segment of latency does it correspond to", it laid out the causal chain and showed me the number's physical meaning was wrong.

The payoff here isn't "Claude wrote me extra code". It's "Claude kept a product lie out of production".

If 23s ago had shipped, no one would have complained — it looks reasonable, and the people who'd notice the bug wouldn't bother flagging it. But your DeFi doc site, in the eyes of someone who actually knows the space, would have a hole: this site doesn't know what it's displaying.

Next time you're making a product decision with Claude, take 5 minutes for this:

"Given the plan I just proposed, list three ways it could be wrong. For each, give a concrete scenario."

If Claude finds one you hadn't considered, those 5 minutes paid for themselves.