Free

Quando o Claude derrubou meu próprio plano do "23s ago"

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.


Primeiro instinto: só colar "23s ago"

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.

Três dimensões independentes

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.

A correção: block + age lado a lado, segmentado por tipo de campo

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.

Implementação: block number é praticamente de graça

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.

A história de produto de verdade

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

Lição: ponha o Claude pra discordar de você

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.