J'ai proposé une étiquette "23s ago" pour des données on-chain. Claude m'a montré que c'était un mensonge produit.
smarts.md est un site de live docs pour smart contracts. Chaque page de contrat affiche un ensemble de valeurs lues directement on-chain — la liquidity d'un pool Uniswap, le totalSupply d'un ERC-20, l'owner d'un contrat custom. Ces valeurs peuvent être périmées de jusqu'à 60 secondes (Solid Cache TTL = 60s).
Mais la page n'avait aucun indicateur du genre "lu quand ?". Ça ressemblait à Mintlify — indistinguable d'un doc statique.
En auditant ma stratégie de rafraîchissement avec Claude, j'avais listé six manques. Le manque 4 tenait en une ligne :
les données live n'ont aucun indicateur de fraîcheur — ajouter un label "as of block X / Y seconds ago".
J'ai écrit la todo et je suis allé déjeuner. En revenant je suis resté bloqué une heure sur cette ligne. La direction que j'ai finalement mergée n'a rien à voir avec mon premier instinct — et Claude m'a aidé à réaliser que le plan d'origine était en fait un mensonge produit.
Ma première idée, c'était :
liquidity: 1,234,567 USDC ← 23s ago
totalSupply: 27.3B USDC ← 12s ago
Simple, direct, l'utilisateur comprend instantanément.
Après le déjeuner j'ai relu, et j'ai commencé à poser quelques questions à Claude :
Ce "23s", il soustrait quoi à quoi exactement ?
Il a explicité : now - cache.fetched_at. Autrement dit, le temps depuis qu'on a écrit la valeur dans le cache.
Et donc, on est en retard de combien par rapport à la tête de chaîne ?
Claude a réfléchi : ton Multicall lit toutes les fonctions view d'un coup, écrit dans le cache, le cache reste valide 60s. Mais le block sur lequel on lit au moment de l'écriture est lui-même déjà dans le passé — Ethereum a un plancher de 0–12s par bloc.
La vraie chaîne causale, c'est :
la tête de chaîne produit un nouveau block
└→ notre RPC lit au block N (retard 0–12s par rapport à la tête)
└→ on écrit dans Solid Cache
└→ rendu sur l'écran de l'utilisateur (cache jusqu'à 60s)
Et le "23s ago" du label, il correspond à quel segment ?
Uniquement au dernier : now - fetched_at, le temps entre l'écriture dans le cache et maintenant.
Le retard côté chaîne n'est absolument pas reflété.
Exemple concret : l'utilisateur voit "fetched 5s ago", mais ce block a déjà 12s, et la tête de chaîne a peut-être produit 2 blocks de plus depuis — retard réel par rapport à la chaîne ≈ 24s, label affiché 5s.
C'est là que le déclic s'est fait : compresser la "fraîcheur" en un nombre unique est physiquement faux.
Claude m'a aidé à le mettre noir sur blanc — il y a trois dimensions indépendantes :
| Dimension | Ce qu'elle mesure | Plancher physique |
|---|---|---|
| Fraîcheur de bloc | tête de chaîne → block lu | block time Ethereum 0–12s |
| Fraîcheur de lecture | lecture RPC → écriture cache | 0 (synchrone) |
| Fraîcheur d'affichage | écriture cache → écran utilisateur | 0–60s (TTL cache) |
Sommer ces trois éléments en un seul "freshness: 35s" n'existe pas physiquement. La chaîne chain → cache → screen n'est pas synchrone : le cache se fige au moment de l'écriture, alors que la chaîne continue à produire des blocks. Ce n'est pas un problème de précision, c'est une erreur de catégorie.
Plus important encore : le segment qui compte dépend du champ lui-même.
decimals : défini dans le constructeur, ne change jamais. N'a pas besoin de fraîcheur.owner : ne change qu'au passage de la governance, peut rester en place pendant des années. Tu as besoin de savoir à quel block c'était lu (pour ne pas regarder une valeur "d'avant cinq upgrades"), mais pas "il y a 23 secondes".liquidity / slot0 : peuvent changer à chaque block. Block et temps importent tous les deux.Le numéro de block est le seul ancrage objectif — c'est la vérité on-chain, non déformée par notre stratégie de cache.
Ce qui est mergé :
liquidity: 1,234,567 USDC
↳ as of Block #19,234,567 · 23s ago
Block pour celui qui sait, "23s ago" pour celui qui ne sait pas. Aucun des deux ne ment : le block est un fait physique, "23s ago" signifie explicitement temps depuis l'écriture dans le cache, pas "retard par rapport à la tête de chaîne".
Affichage différencié par attribut de champ :
# 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
Règles de rendu :
| Classe | Exemple | Affichage |
|---|---|---|
| immutable | decimals, name, symbol |
pas de fraîcheur (ce ne serait que du bruit) |
| slow | owner, paused |
block uniquement, pas de secondes |
| fast | slot0, liquidity, totalSupply |
block + secondes |
La whitelist est délibérément conservatrice — tout ce qui n'y est pas tombe en fast. Mieux vaut sur-marquer une valeur comme live que de montrer en silence une valeur mutable comme statique.
Multicall3 expose lui-même getBlockNumber(). On l'ajoute en queue de chaque batch — un seul RPC, une valeur en plus, latence quasi nulle :
# 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 d'un seul coup
Batch.new(block_number: block_number, results: results)
end
Le payload caché de ViewCaller a aussi pris une version (CACHE_VERSION = "v2"), passant de {fn_sig => result} à Snapshot(results:, block_number:, fetched_at:). Le Snapshot délègue def [](key) = @results[key] et compagnie pour conserver l'interface hash d'origine — aucun caller n'a eu à changer.
Le 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 total : 24 fichiers, +676 / -55.
Une fois en prod, j'ai réécrit le différenciateur :
Smarts est la première doc DeFi qui te dit, champ par champ, à quel block son état a été lu.
C'est plus concret et plus crédible que "on est plus frais que Mintlify". Ça expose la latence réelle au lieu de la planquer — aligné avec les deux principes de mon CLAUDE.md sur lesquels je reviens en boucle : "l'IA est un outil, pas une vitrine" + "Build in Public".
Après plus d'un an à bosser avec Claude, ma conclusion la plus forte n'est pas la vitesse à laquelle il écrit du code — c'est que il argumentera sérieusement contre mon propre plan, à condition que je prenne la peine de m'arrêter et de demander.
Quand j'ai proposé "ajouter un label 23s ago", je ne voyais rien de mal dedans. Si j'avais relu mon propre PR, je l'aurais probablement mergé — ça paraît trop "évidemment juste".
Mais quand j'ai changé de registre et demandé à Claude "ce 23s, il soustrait quoi à quoi exactement, et il correspond à quel segment de latence ?", il a déroulé la chaîne causale et m'a montré que la signification physique du nombre était fausse.
Le payoff ici, ce n'est pas "Claude m'a écrit plus de code". C'est "Claude a empêché un mensonge produit d'arriver en prod".
Si 23s ago avait sorti, personne ne se serait plaint — ça paraît raisonnable, et les gens qui repéreraient le bug ne prendraient pas la peine de le signaler. Mais aux yeux de quelqu'un qui connaît vraiment le sujet, ton site de docs DeFi aurait un trou : ce site ne sait pas ce qu'il affiche.
La prochaine fois que tu prends une décision produit avec Claude, prends 5 minutes pour cette étape :
"Sur le plan que je viens de proposer, liste trois façons dont il pourrait être faux. Pour chacune, donne un scénario concret."
Si Claude en trouve une que tu n'avais pas envisagée, ces 5 minutes sont déjà rentabilisées.