Propus uma label "23s ago" para dados on-chain. O Claude me mostrou que era uma mentira de produto.
smarts.md é um site de live docs para smart contracts. Cada página de contrato mostra um conjunto de valores lidos direto on-chain — liquidity de uma pool da Uniswap, totalSupply de um ERC-20, owner de um contrato qualquer. Esses valores podem estar até 60 segundos defasados (Solid Cache TTL = 60s).
Mas a página não tinha nenhum sinal de "quando isso foi lido?". Parecia Mintlify — indistinguível de um doc estático.
Auditando com o Claude a estratégia de atualização de dados, listei seis lacunas. A lacuna 4 cabia em uma linha:
dados live sem indicador de frescor — adicionar uma label "as of block X / Y seconds ago".
Terminei e fui almoçar. Voltando, fiquei uma hora travado nessa linha. A direção que acabei colocando em produção foi totalmente diferente do meu primeiro instinto — e o Claude me ajudou a perceber que o plano original era uma mentira de produto.
Meu primeiro pensamento foi:
liquidity: 1,234,567 USDC ← 23s ago
totalSupply: 27.3B USDC ← 12s ago
Simples, direto, o usuário entende na hora.
Depois do almoço olhei de novo e comecei a fazer algumas perguntas pro Claude:
Esse "23s" subtrai exatamente o quê de quê?
Ele soletrou: now - cache.fetched_at. Ou seja, tempo desde que escrevemos o valor no cache.
Então quanto estamos atrasados em relação à head da chain?
O Claude pensou: seu Multicall lê todas as funções view de uma vez, escreve no cache, e o cache é válido por 60s. Mas o block que lemos no momento da escrita já está no passado — Ethereum tem um piso de 0–12s por bloco.
A cadeia causal real é:
head da chain produz um block novo
└→ nosso RPC lê no block N (atraso 0–12s em relação à head)
└→ escrevemos no Solid Cache
└→ render na tela do usuário (cache até 60s)
Então o "23s ago" da label corresponde a qual trecho?
Só ao último: now - fetched_at, tempo da escrita no cache até agora.
O atraso do lado da chain não aparece em lugar nenhum.
Exemplo concreto: o usuário vê "fetched 5s ago", mas aquele block já tem 12s, e a head pode ter produzido mais 2 blocks — atraso real ≈ 24s, label diz 5s.
Foi aí que caiu a ficha: comprimir "frescor" num único número é fisicamente errado.
O Claude me ajudou a deixar isso preto no branco — são três dimensões independentes:
| Dimensão | O que mede | Piso físico |
|---|---|---|
| Frescor de bloco | head da chain → block que lemos | tempo de bloco Ethereum 0–12s |
| Frescor de leitura | RPC lê → cache escreve | 0 (síncrono) |
| Frescor de display | cache escreve → tela do usuário | 0–60s (cache TTL) |
Somar esses três em um único "freshness: 35s" não existe fisicamente. A cadeia chain → cache → screen não é síncrona: o cache congela na hora que escreve, enquanto a chain continua produzindo blocks. Não é problema de precisão, é erro de categoria.
Mais importante: qual dos três trechos importa depende do campo em si.
decimals: definido no constructor, nunca muda. Nem precisa de frescor.owner: só muda por governance, pode ficar parado anos. Você precisa saber em qual block foi lido (pra não estar olhando o valor "de cinco upgrades atrás"), mas não "23 segundos atrás".liquidity / slot0: pode mudar a cada bloco. Block e tempo, ambos importam.O block number é o único âncora objetivo — é a verdade on-chain, não distorcida pela nossa estratégia de cache.
O que sai:
liquidity: 1,234,567 USDC
↳ as of Block #19,234,567 · 23s ago
Block pra quem sabe, "23s ago" pra quem não sabe. Nenhum dos dois mente: block é fato físico, "23s ago" significa explicitamente tempo desde a escrita no cache, não "atraso em relação à head da chain".
Display diferenciado por tipo de 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
Regras de render:
| Classe | Exemplo | Display |
|---|---|---|
| immutable | decimals, name, symbol |
sem frescor (seria ruído puro) |
| slow | owner, paused |
só block, sem segundos |
| fast | slot0, liquidity, totalSupply |
block + segundos |
A whitelist é deliberadamente conservadora — tudo que não estiver na lista cai em fast. Melhor super-marcar algo como vivo do que mostrar silenciosamente um valor mutável como estático.
O próprio Multicall3 expõe getBlockNumber(). Adicionamos no fim de cada batch — um RPC, um valor a mais, latência quase nula:
# 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 de uma vez
Batch.new(block_number: block_number, results: results)
end
O payload cacheado do ViewCaller também subiu de versão (CACHE_VERSION = "v2"), saindo de {fn_sig => result} pra Snapshot(results:, block_number:, fetched_at:). O Snapshot delega def [](key) = @results[key] e companhia pra manter a interface hash original — nenhum caller precisou mudar.
O helper de 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 total: 24 arquivos, +676 / -55.
Depois que subiu, reescrevi o diferencial:
A Smarts é a primeira doc de DeFi que te diz, campo por campo, em qual block o estado foi lido.
É mais concreto e mais crível do que "somos mais frescos que a Mintlify". Expõe a latência real em vez de esconder — alinhado com os dois princípios do meu CLAUDE.md aos quais sempre volto: "IA é ferramenta, não vitrine" + "Build in Public".
Depois de mais de um ano trabalhando com o Claude, minha maior conclusão não é a velocidade dele escrevendo código — é que ele vai discutir seriamente contra meu próprio plano se eu me der ao trabalho de parar e perguntar.
Quando propus "adicionar uma label 23s ago", não vi nada de errado. Se eu mesmo tivesse revisado meu PR, provavelmente teria mergeado — parece "obviamente certo".
Mas quando mudei o tom e perguntei pro Claude "esse 23s subtrai o quê de quê exatamente, e corresponde a qual trecho de latência?", ele desenrolou a cadeia causal e mostrou que o significado físico do número estava errado.
O payoff aqui não é "o Claude escreveu mais código pra mim". É "o Claude impediu uma mentira de produto de chegar em produção".
Se o 23s ago tivesse subido, ninguém teria reclamado — parece razoável, e quem perceberia o bug não se daria ao trabalho de avisar. Mas pra quem realmente entende do assunto, seu site de docs de DeFi teria um buraco: esse site não sabe o que está exibindo.
Na próxima vez que tomar uma decisão de produto com o Claude, separe 5 minutos pra isto:
"Sobre o plano que acabei de propor, liste três jeitos dele estar errado. Pra cada um, dê um cenário concreto."
Se o Claude achar um que você não tinha considerado, esses 5 minutos já se pagaram.