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.
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.
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 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.
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.
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".
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.