Ho proposto un'etichetta "23s ago" per dati on-chain. Claude mi ha mostrato che era una bugia di prodotto.
smarts.md è un sito di live docs per smart contract. Ogni pagina di contratto mostra una serie di valori letti direttamente on-chain — la liquidity di una pool Uniswap, il totalSupply di un ERC-20, l'owner di un contratto qualsiasi. Questi valori possono essere fino a 60 secondi vecchi (Solid Cache TTL = 60s).
Ma la pagina non aveva nessun indicatore "letto quando?". Sembrava Mintlify — indistinguibile da un documento statico.
Mentre auditavo con Claude la strategia di refresh dei dati, ho elencato sei lacune. La lacuna 4 stava in una riga:
dati live senza indicatore di freschezza — aggiungere una label "as of block X / Y seconds ago".
Ho scritto la todo e sono andato a pranzo. Tornato sono rimasto bloccato un'ora su quella riga. La direzione che ho mergiato alla fine è completamente diversa dal mio primo istinto — e Claude mi ha aiutato a capire che il piano originario era in realtà una bugia di prodotto.
Il mio primo pensiero era questo:
liquidity: 1,234,567 USDC ← 23s ago
totalSupply: 27.3B USDC ← 12s ago
Semplice, diretto, l'utente lo capisce all'istante.
Dopo pranzo ho riguardato e ho cominciato a fare qualche domanda a Claude:
Questo "23s" sottrae cosa da cosa, esattamente?
L'ha messo nero su bianco: now - cache.fetched_at. Cioè, il tempo da quando abbiamo scritto il valore in cache.
E quanto siamo davvero indietro rispetto alla testa di catena?
Claude ci ha pensato: il tuo Multicall legge tutte le funzioni view in un colpo, scrive in cache, la cache è valida 60s. Ma il block su cui leggiamo nel momento della scrittura è esso stesso già nel passato — Ethereum ha un floor di 0–12s per blocco.
La vera catena causale è:
la testa di catena produce un block nuovo
└→ il nostro RPC legge al block N (in ritardo 0–12s rispetto alla testa)
└→ scriviamo in Solid Cache
└→ render sullo schermo dell'utente (cache fino a 60s)
E quindi il "23s ago" della label a quale segmento corrisponde?
Solo all'ultimo: now - fetched_at, il tempo dalla scrittura in cache a ora.
Il ritardo lato catena non è riflesso per niente.
Esempio concreto: l'utente vede "fetched 5s ago", ma quel block ha già 12s di vita, e la testa potrebbe aver prodotto altri 2 block — ritardo reale rispetto alla catena ≈ 24s, label dice 5s.
Lì è scattato il click: comprimere la "freschezza" in un singolo numero è fisicamente sbagliato.
Claude mi ha aiutato a metterlo per iscritto in modo chiaro — sono tre dimensioni indipendenti:
| Dimensione | Cosa misura | Floor fisico |
|---|---|---|
| Freschezza di blocco | testa catena → block letto | block time Ethereum 0–12s |
| Freschezza di lettura | lettura RPC → scrittura cache | 0 (sincrona) |
| Freschezza di display | scrittura cache → schermo utente | 0–60s (TTL cache) |
Sommare le tre cose in un singolo "freshness: 35s" non esiste fisicamente. La catena chain → cache → screen non è sincrona: la cache si congela nel momento della scrittura, mentre la chain continua a produrre block. Non è un problema di precisione, è un errore di categoria.
Più importante: quale segmento conta dipende dal campo stesso.
decimals: definito nel costruttore, non cambia mai. Non serve freschezza.owner: cambia solo via governance, può restare fermo per anni. Vuoi sapere a che block è stato letto (per non guardare il valore "di cinque upgrade fa"), ma non "23 secondi fa".liquidity / slot0: può cambiare ad ogni block. Block e tempo, entrambi contano.Il numero di block è l'unico ancoraggio oggettivo — è la verità on-chain, non distorta dalla nostra strategia di cache.
Cosa è andato live:
liquidity: 1,234,567 USDC
↳ as of Block #19,234,567 · 23s ago
Block per chi sa, "23s ago" per chi non sa. Nessuno dei due mente: il block è un fatto fisico, "23s ago" significa esplicitamente tempo dalla scrittura in cache, non "ritardo rispetto alla testa di catena".
Display differenziato per attributo del campo:
# 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
Regole di render:
| Classe | Esempio | Display |
|---|---|---|
| immutable | decimals, name, symbol |
nessuna freschezza (sarebbe solo rumore) |
| slow | owner, paused |
solo block, niente secondi |
| fast | slot0, liquidity, totalSupply |
block + secondi |
La whitelist è volutamente conservativa — tutto ciò che non c'è cade in fast. Meglio sovra-marcare un valore come live che mostrare in silenzio un valore mutabile come statico.
Multicall3 espone una propria getBlockNumber(). Aggiungila in fondo a ogni batch — un RPC solo, un valore in più, latenza pressoché nulla:
# 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 un colpo solo
Batch.new(block_number: block_number, results: results)
end
Anche il payload cached di ViewCaller ha avuto un bump di versione (CACHE_VERSION = "v2"), passando da {fn_sig => result} a Snapshot(results:, block_number:, fetched_at:). Snapshot delega def [](key) = @results[key] e affini per mantenere intatta l'interfaccia hash originale — nessun caller ha dovuto cambiare.
L'helper UI:
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 totale: 24 file, +676 / -55.
Una volta in produzione ho riscritto il differenziatore:
Smarts è la prima documentazione DeFi che ti dice, campo per campo, a quale block è stato letto il suo stato.
Più concreto e più credibile di "siamo più freschi di Mintlify". Espone la latenza reale invece di nasconderla — allineato ai due principi del mio CLAUDE.md ai quali torno sempre: "l'AI è uno strumento, non una vetrina" + "Build in Public".
Dopo più di un anno a lavorare con Claude, la mia conclusione più forte non è la velocità con cui scrive codice — è che discuterà seriamente contro il mio piano se mi prendo la briga di fermarmi e chiedere.
Quando ho proposto "aggiungere una label 23s ago", io stesso non vedevo nulla di sbagliato. Se avessi rivisto il mio PR, probabilmente l'avrei mergiato — sembra troppo "ovviamente giusto".
Ma quando ho cambiato registro e ho chiesto a Claude "questo 23s sottrae cosa da cosa, e a quale segmento di latenza corrisponde?", ha srotolato la catena causale e mi ha mostrato che il significato fisico del numero era sbagliato.
Il payoff qui non è "Claude mi ha scritto più codice". È "Claude ha tenuto fuori dalla produzione una bugia di prodotto".
Se 23s ago fosse davvero andato live, nessuno si sarebbe lamentato — sembra ragionevole, e chi se ne accorgerebbe non si prende la briga di segnalarlo. Ma agli occhi di chi conosce davvero il settore, il tuo sito di docs DeFi avrebbe un buco: questo sito non sa cosa sta mostrando.
La prossima volta che prendi una decisione di prodotto con Claude, dedica 5 minuti a questo passaggio:
"Sul piano che ho appena proposto, elenca tre modi in cui potrebbe essere sbagliato. Per ciascuno, dammi uno scenario concreto."
Se Claude ne trova uno che non avevi considerato, quei 5 minuti si sono già ripagati.