Propuse una etiqueta "23s ago" para datos on-chain. Claude me hizo ver que era una mentira de producto.
smarts.md es un sitio de live docs para contratos inteligentes. Cada página de contrato muestra un grupo de valores leídos directamente on-chain — liquidity de un pool de Uniswap, totalSupply de un ERC-20, owner de un contrato cualquiera. Esos valores pueden tener hasta 60 segundos de antigüedad (Solid Cache TTL = 60s).
Pero la página no tenía ninguna señal de "¿cuándo se leyó esto?". Parecía Mintlify — indistinguible de un documento estático.
Auditando con Claude la estrategia de actualización de datos, listamos seis huecos. El hueco 4 era una sola línea:
los datos en vivo no muestran frescura — añadir una etiqueta "as of block X / Y seconds ago".
Terminé y me fui a comer. Al volver me quedé una hora atrancado en esa línea. La dirección que terminé subiendo a producción fue completamente distinta a mi instinto inicial — y Claude me ayudó a darme cuenta de que el plan original era una mentira de producto.
Mi primera idea fue así:
liquidity: 1,234,567 USDC ← 23s ago
totalSupply: 27.3B USDC ← 12s ago
Sencillo, directo, el usuario lo entiende al instante.
Después de comer volví a mirar y empecé a hacerle preguntas a Claude:
¿Qué resta el "23s" exactamente, de qué a qué?
Lo deletreó: now - cache.fetched_at. Es decir, el tiempo desde que escribimos el valor en la caché.
Entonces, ¿cuán retrasados estamos respecto al head de la cadena?
Claude lo pensó: tu Multicall lee todas las funciones view de golpe, las escribe en caché y la caché es válida 60s. Pero el block en el que leemos en el momento de escribir ya está en el pasado — Ethereum tiene un suelo de 0–12s por bloque.
La cadena causal real es:
el head de la cadena produce un block nuevo
└→ nuestro RPC lee en el block N (retraso 0–12s respecto al head)
└→ escribimos en Solid Cache
└→ render en pantalla del usuario (caché hasta 60s)
¿Y a qué tramo corresponde el "23s ago"?
Solo al último tramo: now - fetched_at, el tiempo desde la escritura en caché hasta ahora.
El retraso del lado de la cadena no se refleja en absoluto.
Ejemplo concreto: el usuario ve "fetched 5s ago", pero ese block ya tiene 12s de antigüedad y el head puede haber producido 2 bloques más — el retraso real respecto a la cadena ≈ 24s, la etiqueta dice 5s.
Ahí me di cuenta: comprimir la "frescura" en un único número es físicamente erróneo.
Claude me ayudó a ponerlo por escrito — son tres dimensiones independientes:
| Dimensión | Qué mide | Suelo físico |
|---|---|---|
| Frescura de bloque | head de cadena → block leído | tiempo de bloque Ethereum 0–12s |
| Frescura de lectura | RPC lee → cache escribe | 0 (síncrono) |
| Frescura de display | cache escribe → pantalla del usuario | 0–60s (cache TTL) |
Sumar estos tres en un único "freshness: 35s" no existe físicamente. La cadena chain → cache → screen no es síncrona: la caché se congela en el momento de escribir, mientras la cadena sigue produciendo bloques. No es un problema de precisión, es un error de categoría.
Más importante: qué tramo importa depende del campo en sí mismo.
decimals: definido en el constructor, nunca cambia. No necesita frescura.owner: solo cambia con governance, puede pasar años igual. Necesitas saber en qué block se leyó (para no estar viendo el valor "de hace cinco upgrades"), pero no "hace 23 segundos".liquidity / slot0: pueden cambiar bloque a bloque. Block y tiempo, ambos importan.El block number es el único anclaje objetivo — es la verdad on-chain, sin distorsión por nuestra estrategia de caché.
Lo que sale:
liquidity: 1,234,567 USDC
↳ as of Block #19,234,567 · 23s ago
Block para quien sabe, "23s ago" para quien no. Ninguno miente: el block es un hecho físico, "23s ago" significa explícitamente tiempo desde la escritura en caché, no "retraso respecto al head de la cadena".
Display diferenciado por atributo 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
Reglas de render:
| Clase | Ejemplo | Display |
|---|---|---|
| immutable | decimals, name, symbol |
sin frescura (sería ruido puro) |
| slow | owner, paused |
solo block, sin segundos |
| fast | slot0, liquidity, totalSupply |
block + segundos |
La whitelist es deliberadamente conservadora — todo lo que no esté en la lista cae en fast. Mejor sobre-marcar algo como vivo que mostrar silenciosamente un valor mutable como estático.
Multicall3 expone un getBlockNumber() propio. Lo añadimos al final de cada batch — un solo RPC, un valor extra, latencia casi 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 un tirón
Batch.new(block_number: block_number, results: results)
end
El payload cacheado de ViewCaller también subió de versión (CACHE_VERSION = "v2"), pasando de {fn_sig => result} a Snapshot(results:, block_number:, fetched_at:). Snapshot delega def [](key) = @results[key] y similares para mantener la interfaz hash original — ningún caller tuvo que cambiar.
El 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 archivos, +676 / -55.
Una vez en producción, reescribí el diferenciador:
Smarts es la primera documentación de DeFi que te dice, campo por campo, en qué block se leyó su estado.
Es más concreto y más creíble que "somos más frescos que Mintlify". Expone la latencia real en vez de esconderla — alineado con los dos principios de mi CLAUDE.md a los que vuelvo siempre: "la IA es una herramienta, no un escaparate" + "Build in Public".
Después de más de un año trabajando con Claude, mi mayor conclusión no es lo rápido que escribe código — es que discutirá en serio contra mi propio plan si me molesto en pararme y preguntarle.
Cuando propuse "añadir una etiqueta 23s ago", no vi nada mal. Si yo mismo hubiera revisado el PR, probablemente lo habría mergeado — parece "obviamente correcto".
Pero al cambiar el tono y preguntarle a Claude "ese 23s, ¿qué resta de qué exactamente, y a qué tramo de latencia corresponde?", desplegó la cadena causal y me mostró que el significado físico del número estaba equivocado.
El payoff aquí no es "Claude me escribió más código". Es "Claude evitó que una mentira de producto llegara a producción".
Si 23s ago hubiera salido, nadie se habría quejado — parece razonable, y la gente que detectaría el bug no se molesta en avisar. Pero a ojos de quien sabe del tema, tu sitio de docs DeFi tendría un agujero: este sitio no sabe lo que está mostrando.
La próxima vez que tomes una decisión de producto con Claude, dedica 5 minutos a esto:
"Sobre el plan que acabo de proponer, lista tres formas en que podría estar equivocado. Para cada una, da un escenario concreto."
Si Claude encuentra una que no habías considerado, esos 5 minutos se pagan solos.