Free

Quand Claude a démonté mon propre plan "23s ago"

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.


Premier instinct : coller "23s ago" et basta

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.

Trois dimensions indépendantes

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.

La correction : block + age côte à côte, segmenté par type de champ

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.

Implémentation : le numéro de block est quasi gratuit

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.

La vraie histoire produit

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

Leçon : faire débattre Claude contre toi

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.