Ich schlug ein "23s ago"-Label für On-Chain-Daten vor. Claude zeigte mir: das ist eine Produktlüge.
smarts.md ist eine Live-Docs-Seite für Smart Contracts. Jede Contract-Seite zeigt eine Reihe von Werten, die direkt on-chain gelesen werden — Liquidity eines Uniswap-Pools, totalSupply eines ERC-20, owner eines beliebigen Contracts. Diese Werte können bis zu 60 Sekunden alt sein (Solid Cache TTL = 60s).
Aber die Seite hatte keinerlei Hinweis auf "wann wurde das gelesen?". Sah aus wie Mintlify — nicht von einem statischen Doc zu unterscheiden.
Beim Audit der Daten-Refresh-Strategie mit Claude habe ich sechs Lücken aufgelistet. Lücke 4 war eine einzelne Zeile:
Live-Daten ohne Frische-Anzeige — füge ein "as of block X / Y seconds ago"-Label hinzu.
Ich schrieb die Todo und ging Mittagessen. Zurück blieb ich eine Stunde an dieser einen Zeile hängen. Die Richtung, die ich am Ende gemerged habe, war komplett anders als mein erster Instinkt — und Claude hat mir geholfen zu erkennen, dass der ursprüngliche Plan eigentlich eine Produkt-Lüge war.
Mein erster Gedanke war:
liquidity: 1,234,567 USDC ← 23s ago
totalSupply: 27.3B USDC ← 12s ago
Schlicht, direkt, der User versteht es sofort.
Nach dem Mittagessen schaute ich nochmal hin und stellte Claude ein paar Fragen:
Was zieht dieser "23s" eigentlich von was ab?
Es buchstabierte es aus: now - cache.fetched_at. Also die Zeit, seit wir den Wert in den Cache geschrieben haben.
Und wie weit hinken wir tatsächlich hinter dem Chain-Head her?
Claude überlegte: dein Multicall liest alle View-Funktionen auf einmal, schreibt sie in den Cache, der Cache bleibt 60s gültig. Aber der Block, an dem wir im Moment des Schreibens lesen, ist selbst schon Vergangenheit — Ethereum hat eine Untergrenze von 0–12s pro Block.
Die echte kausale Kette:
Chain-Head produziert einen neuen Block
└→ unser RPC liest bei Block N (0–12s hinter dem Head)
└→ wir schreiben in Solid Cache
└→ Render auf dem User-Bildschirm (Cache bis zu 60s)
Und welchem Segment entspricht dann das "23s ago"-Label?
Nur dem letzten Segment: now - fetched_at, die Zeit von Cache-Write bis jetzt.
Der Lag auf der Chain-Seite wird überhaupt nicht abgebildet.
Konkretes Beispiel: User sieht "fetched 5s ago", aber der Block ist selbst schon 12s alt, und der Chain-Head hat vielleicht 2 weitere Blöcke produziert — realer Chain-Lag ≈ 24s, Label sagt 5s.
In dem Moment ist der Groschen gefallen: "Frische" auf eine einzelne Zahl zu verdichten ist physikalisch falsch.
Claude half mir, das sauber aufzuschreiben — es sind drei unabhängige Dimensionen:
| Dimension | Was sie misst | Physikalische Untergrenze |
|---|---|---|
| Block-Frische | Chain-Head → von uns gelesener Block | Ethereum-Blocktime 0–12s |
| Read-Frische | RPC-Read → Cache-Write | 0 (synchron) |
| Display-Frische | Cache-Write → User-Bildschirm | 0–60s (Cache-TTL) |
Die drei zu einer einzelnen "freshness: 35s"-Zahl zu summieren existiert physikalisch nicht. Die chain → cache → screen-Kette ist nicht synchron: der Cache friert im Moment des Schreibens ein, während die Chain weiter Blöcke produziert. Das ist kein Genauigkeitsproblem, sondern ein Kategorienfehler.
Wichtiger noch: welches Segment wichtig ist, hängt vom Feld selbst ab.
decimals: im Konstruktor definiert, ändert sich nie. Braucht gar keine Frische.owner: ändert sich nur durch Governance, kann jahrelang stehen bleiben. Du willst wissen, an welchem Block das gelesen wurde (damit du nicht den Wert "von vor fünf Upgrades" siehst), aber kein "vor 23 Sekunden".liquidity / slot0: kann sich pro Block ändern. Block und Zeit sind beide wichtig.Die Block-Nummer ist der einzige objektive Anker — sie ist die Wahrheit on-chain, durch unsere Caching-Strategie nicht verzerrt.
Was rauskam:
liquidity: 1,234,567 USDC
↳ as of Block #19,234,567 · 23s ago
Block für die, die Bescheid wissen, "23s ago" für alle anderen. Keines der beiden lügt: Block ist physische Tatsache, "23s ago" bedeutet explizit Zeit seit Cache-Write, nicht "Lag hinter Chain-Head".
Differenzierte Anzeige nach Feldattribut:
# 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-Regeln:
| Klasse | Beispiel | Anzeige |
|---|---|---|
| immutable | decimals, name, symbol |
keine Frische-Anzeige (wäre nur Rauschen) |
| slow | owner, paused |
nur Block, keine Sekunden |
| fast | slot0, liquidity, totalSupply |
Block + Sekunden |
Die Whitelist ist bewusst konservativ — alles, was nicht draufsteht, fällt in fast. Lieber etwas zu Unrecht als "live" markieren, als einen veränderlichen Wert stillschweigend statisch anzeigen.
Multicall3 selbst stellt getBlockNumber() bereit. Hänge es ans Ende jedes Batches — ein RPC, ein zusätzlicher Wert, fast keine Latenz:
# 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 einem Rutsch
Batch.new(block_number: block_number, results: results)
end
Auch das gecachte Payload von ViewCaller bekam einen Versionsschritt (CACHE_VERSION = "v2"), von {fn_sig => result} zu Snapshot(results:, block_number:, fetched_at:). Snapshot delegiert def [](key) = @results[key] und Co., um die alte Hash-Schnittstelle beizubehalten — kein Caller musste angefasst werden.
Der 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 insgesamt: 24 Dateien, +676 / -55.
Nach dem Release habe ich das Differenzierungsmerkmal neu geschrieben:
Smarts ist die erste DeFi-Doku, die dir pro Feld sagt, an welchem Block der Status ausgelesen wurde.
Konkreter und glaubwürdiger als "wir sind frischer als Mintlify". Sie legt die echte Latenz offen, statt sie zu verstecken — passt zu den zwei CLAUDE.md-Prinzipien, auf die ich immer wieder zurückkomme: "AI ist Werkzeug, kein Schaufenster" + "Build in Public".
Nach über einem Jahr Arbeit mit Claude ist meine größte Erkenntnis nicht, wie schnell es Code schreibt — sondern dass es ernsthaft gegen meinen eigenen Plan argumentiert, sofern ich mir die Mühe mache, kurz innezuhalten und nachzufragen.
Als ich "23s ago-Label hinzufügen" vorgeschlagen habe, sah ich selbst nichts Falsches daran. Hätte ich meinen eigenen PR gereviewt, wäre er wahrscheinlich gemerged worden — es sieht zu sehr nach "offensichtlich richtig" aus.
Aber als ich den Tonfall wechselte und Claude fragte "was zieht dieser 23s genau von was ab und welchem Latenz-Segment entspricht er?", hat es die Kausalkette ausgerollt und mir gezeigt, dass die physikalische Bedeutung der Zahl falsch war.
Der Payoff hier ist nicht "Claude hat mir mehr Code geschrieben". Er ist "Claude hat eine Produkt-Lüge davon abgehalten, in Produktion zu gehen".
Wenn 23s ago tatsächlich live gegangen wäre, hätte sich niemand beschwert — sieht plausibel aus, und die Leute, die den Bug bemerken würden, melden ihn nicht. Aber für jemanden, der das Feld wirklich kennt, hätte deine DeFi-Docs-Seite ein Loch: diese Seite weiß nicht, was sie anzeigt.
Beim nächsten Produktentscheid mit Claude nimm dir 5 Minuten für diesen Schritt:
"Zu dem Plan, den ich gerade vorgeschlagen habe: zähl drei Möglichkeiten auf, wie er falsch sein könnte. Für jede ein konkretes Szenario."
Wenn Claude eine findet, an die du nicht gedacht hast, haben sich diese 5 Minuten gelohnt.