Free

Quando Claude ha smontato il mio piano "23s ago"

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.


Primo istinto: appiccicare un "23s ago" e via

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.

Tre dimensioni indipendenti

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.

La correzione: block + age affiancati, segmentato per tipo di campo

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.

Implementazione: il numero di block è praticamente gratis

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.

La vera storia di prodotto

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

Lezione: fai discutere Claude contro di te

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.