Aller au contenu
·14 min de lecture

Les donnees on-chain en production : ce que personne ne vous dit

Les donnees blockchain ne sont ni propres, ni fiables, ni simples. Rate limits RPC, reorganisations de chaine, bugs BigInt et compromis d'indexation — leçons durement apprises en livrant de vrais produits DeFi.

Partager:X / TwitterLinkedIn

Il y a ce fantasme que les donnees on-chain sont inheremment fiables. Registre immuable. État transparent. Il suffit de les lire et c'est termine.

J'y croyais aussi. Puis j'ai mis un tableau de bord DeFi en production et j'ai passé trois semaines a comprendre pourquoi nos soldes de tokens etaient faux, notre historique d'événements avait des trous, et notre base de donnees contenait des transactions de blocs qui n'existaient plus.

Les donnees on-chain sont brutes, hostiles et pleines de cas limites qui casseront votre application de manieres que vous ne remarquerez pas tant qu'un utilisateur n'aura pas soumis un rapport de bug. Cet article couvre tout ce que j'ai appris a mes depens.

L'illusion de donnees fiables#

Voici la première chose que personne ne vous dit : la blockchain ne vous donné pas des donnees. Elle vous donné des transitions d'état. Il n'y a pas de SELECT * FROM transfers WHERE user = '0x...'. Il y a des logs, des recus, des slots de stockage et des traces d'appels — le tout encode dans des formats qui necessitent du contexte pour être decodes.

Un log d'événement Transfer vous donné from, to et value. Il ne vous dit pas le symbole du token. Il ne vous dit pas les decimales. Il ne vous dit pas s'il s'agit d'un transfert legitime ou d'un token fee-on-transfer qui preleve 3% au passage. Il ne vous dit pas si ce bloc existera encore dans 30 secondes.

La partie "immuable" est vraie — une fois finalise. Mais la finalisation n'est pas instantanee. Et les donnees que vous recevez d'un noeud RPC ne proviennent pas necessairement d'un bloc finalise. La plupart des développeurs interrogent latest et le traitent comme la vérité. C'est un bug, pas une feature.

Ensuite il y a l'encodage. Tout est en hexadecimal. Les adresses ont un checksum en casse mixte (ou pas). Les montants de tokens sont des entiers multiplies par 10^decimals. Un transfert USDC de 100 $ ressemble a 100000000 on-chain parce que USDC a 6 decimales, pas 18. J'ai vu du code de production qui supposait 18 decimales pour chaque token ERC-20. Les soldes resultants etaient faux d'un facteur de 10^12.

Les rate limits RPC vont ruiner votre week-end#

Chaque application Web3 en production communique avec un endpoint RPC. Et chaque endpoint RPC a des rate limits bien plus agressifs que ce a quoi vous vous attendez.

Voici les chiffres qui comptent :

  • Alchemy Free : ~30M d'unites de calcul/mois, 40 requêtes/minute. Ca semble genereux jusqu'a ce que vous realisiez qu'un seul appel eth_getLogs sur une large plage de blocs peut consommer des centaines de CU. Vous brulerez votre quota mensuel en une journee d'indexation.
  • Infura Free : 100K requêtes/jour, environ 1,15 req/sec. Essayez de paginer a travers 500K blocs de logs d'événements a ce rythme.
  • QuickNode Free : Similaire a Infura — 100K requêtes/jour.

Les forfaits payants aident, mais n'eliminent pas le problème. Même a 200 $/mois sur le plan Growth d'Alchemy, un job d'indexation intensif atteindra les limites de debit. Et quand vous les atteignez, vous n'obtenez pas une degradation gracieuse. Vous obtenez des erreurs 429, parfois avec des messages peu utiles, parfois sans header retry-after.

La solution est une combinaison de providers de secours, de logique de retry et d'une attention deliberee aux appels que vous effectuez. Voici a quoi ressemble une configuration RPC robuste avec viem :

typescript
import { createPublicClient, fallback, http } from "viem";
import { mainnet } from "viem/chains";
 
const client = createPublicClient({
  chain: mainnet,
  transport: fallback(
    [
      http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
        retryCount: 3,
        retryDelay: 1500,
        timeout: 15_000,
      }),
      http("https://mainnet.infura.io/v3/YOUR_KEY", {
        retryCount: 3,
        retryDelay: 1500,
        timeout: 15_000,
      }),
      http("https://rpc.ankr.com/eth", {
        retryCount: 2,
        retryDelay: 2000,
        timeout: 20_000,
      }),
    ],
    { rank: true }
  ),
});

L'option rank: true est cruciale. Elle dit a viem de mesurer la latence et le taux de succès de chaque transport et de preferer automatiquement le plus rapide et le plus fiable. Si Alchemy commencé a vous limiter, viem redirige le trafic vers Infura. Si Infura tombé, il bascule sur Ankr.

Mais il y a une subtilite : la logique de retry par défaut de viem utilisé un backoff exponentiel, ce qui est généralement ce que vous voulez. Cependant, debut 2025, il existe un problème connu ou retryCount ne retry pas correctement les erreurs au niveau RPC (comme les 429) quand le mode batch est active. Si vous regroupez les requêtes, testez explicitement votre comportement de retry. Ne supposez pas que ca fonctionne.

Les reorgs : le bug que vous ne verrez pas venir#

Une reorganisation de chaine se produit quand le réseau est temporairement en desaccord sur quel bloc est canonique. Le noeud A voit le bloc 1000 avec les transactions [A, B, C]. Le noeud B voit un bloc 1000 différent avec les transactions [A, D]. Finalement le réseau converge, et une version gagne.

Sur les chaines en proof-of-work, c'etait frequent — des reorgs de 1-3 blocs se produisaient plusieurs fois par jour. Ethereum post-merge est mieux. Une attaque de reorg reussie nécessité desormais la coordination de pres de 50% des validateurs. Mais "mieux" ne veut pas dire "impossible". Il y a eu un reorg notable de 7 blocs sur la Beacon Chain en mai 2022, cause par des implementations incoherentes du proposer boost fork entre les clients.

Et peu importe la rarete des reorgs sur le mainnet Ethereum. Si vous construisez sur des L2 ou des sidechains — Polygon, Arbitrum, Optimism — les reorgs sont plus frequents. Polygon a historiquement eu des reorgs de 10+ blocs.

Voici le problème concret : vous avez indexe le bloc 18 000 000. Vous avez ecrit des événements dans votre base de donnees. Puis le bloc 18 000 000 a été reorge. Maintenant votre base de donnees contient des événements d'un bloc qui n'existe plus sur la chaine canonique. Ces événements peuvent referencer des transactions qui n'ont jamais eu lieu. Vos utilisateurs voient des transferts fantomes.

La solution depend de votre architecture :

Option 1 : Delai de confirmation. N'indexez pas les donnees avant que N blocs de confirmation ne soient passes. Pour le mainnet Ethereum, 64 blocs (deux epochs) vous donné des garanties de finalite. Pour les L2, verifiez le modele de finalite spécifique de la chaine. C'est simple mais ajoute de la latence — environ 13 minutes sur Ethereum.

Option 2 : Detection de reorg et rollback. Indexez agressivement mais suivez les hashes de blocs. A chaque nouveau bloc, verifiez que le hash parent correspond au bloc precedent que vous avez indexe. Si ce n'est pas le cas, vous avez detecte un reorg : supprimez tout des blocs orphelins et re-indexez la chaine canonique.

typescript
interface IndexedBlock {
  number: bigint;
  hash: `0x${string}`;
  parentHash: `0x${string}`;
}
 
async function detectReorg(
  client: PublicClient,
  lastIndexed: IndexedBlock
): Promise<{ reorged: boolean; depth: number }> {
  const currentBlock = await client.getBlock({
    blockNumber: lastIndexed.number,
  });
 
  if (currentBlock.hash === lastIndexed.hash) {
    return { reorged: false, depth: 0 };
  }
 
  // Remonter en arriere pour trouver ou la chaine a diverge
  let depth = 1;
  let checkNumber = lastIndexed.number - 1n;
 
  while (checkNumber > 0n && depth < 128) {
    const onChain = await client.getBlock({ blockNumber: checkNumber });
    const inDb = await getIndexedBlock(checkNumber); // votre lookup en base de donnees
 
    if (onChain.hash === inDb?.hash) {
      return { reorged: true, depth };
    }
 
    depth++;
    checkNumber--;
  }
 
  return { reorged: true, depth };
}

Ce n'est pas hypothetique. J'ai eu un système en production ou nous indexions les événements a la pointe de la chaine sans detection de reorg. Pendant trois semaines ca a fonctionne parfaitement. Puis un reorg de 2 blocs sur Polygon a cause un événement de mint NFT en doublon dans notre base de donnees. Le frontend montrait a un utilisateur qu'il possedait un token qu'il n'avait pas. Celui-la a pris deux jours a debugger parce que personne ne cherchait les reorgs comme cause racine.

Le problème d'indexation : choisissez votre galere#

Vous avez trois vraies options pour faire entrer des donnees on-chain structurees dans votre application.

Appels RPC directs#

Appelez simplement getLogs, getBlock, getTransaction directement. Ca fonctionne pour des lectures a petite échelle — vérifier le solde d'un utilisateur, récupérer les événements recents d'un seul contrat. Ca ne fonctionne pas pour l'indexation historique ou les requêtes complexes a travers plusieurs contrats.

Le problème est combinatoire. Vous voulez tous les swaps Uniswap V3 des 30 derniers jours ? Ca represente ~200K blocs. Avec la limite de 2K blocs par appel getLogs d'Alchemy, ca fait 100 requêtes paginees minimum. Chacune compte contre votre rate limit. Et si un appel echoue, vous avez besoin d'une logique de retry, de suivi de curseur et d'un moyen de reprendre la ou vous vous etes arrêté.

The Graph (subgraphs)#

The Graph etait la solution originale. Definissez un schéma, ecrivez des mappings en AssemblyScript, deployez et interrogez avec GraphQL. Le Hosted Service a été deprecie — tout est maintenant sur le Graph Network decentralise, ce qui signifie que vous payez avec des tokens GRT pour les requêtes.

Le positif : standardise, bien documente, large ecosysteme de subgraphs existants que vous pouvez forker.

Le negatif : AssemblyScript est penible. Le débogage est limite. Le déploiement prend de quelques minutes a plusieurs heures. Si votre subgraph a un bug, vous redeployez et attendez qu'il se resynchronise depuis zero. Le réseau decentralise ajoute de la latence et parfois les indexeurs sont en retard par rapport a la pointe de la chaine.

J'ai utilisé The Graph pour des tableaux de bord en lecture intensive ou une fraicheur des donnees de 30-60 secondes est acceptable. Ca fonctionne bien dans ce cas. Je ne l'utiliserais pas pour quoi que ce soit necessitant des donnees en temps reel ou une logique metier complexe dans les mappings.

Indexeurs personnalises (Ponder, Envio)#

C'est la que l'ecosysteme a considerablement muri. Ponder et Envio vous permettent d'écrire la logique d'indexation en TypeScript (pas en AssemblyScript), de tourner en local pendant le développement et de déployer comme des services autonomes.

Ponder vous donné un contrôle maximal. Vous definissez des handlers d'événements en TypeScript, il gere le pipeline d'indexation et vous obtenez une base de donnees SQL en sortie. Le compromis : vous gerez l'infrastructure. Mise a l'échelle, monitoring, gestion des reorgs — c'est votre responsabilite.

Envio optimise la vitesse de synchronisation. Leurs benchmarks montrent des temps de synchronisation initiale significativement plus rapides que The Graph. Ils gerent les reorgs nativement et supportent HyperSync, un protocole specialise pour une recuperation de donnees plus rapide. Le compromis : vous vous engagez dans leur infrastructure et leur API.

Ma recommandation : si vous construisez une application DeFi en production et que vous avez la capacite d'ingenierie, utilisez Ponder. Si vous avez besoin de la synchronisation la plus rapide possible et ne voulez pas gérer l'infrastructure, evaluez Envio. Si vous avez besoin d'un prototype rapide ou voulez des subgraphs maintenus par la communaute, The Graph est toujours correct.

getLogs est plus dangereux qu'il n'y parait#

La méthode RPC eth_getLogs est d'une simplicite trompeuse. Donnez-lui une plage de blocs et quelques filtres, recuperez les logs d'événements correspondants. Voici ce qui se passé réellement en production :

Les limites de plage de blocs varient selon le provider. Alchemy plafonne a 2K blocs (logs illimites) ou blocs illimites (max 10K logs). Infura a des limites differentes. QuickNode a des limites differentes. Un RPC public peut plafonner a 1K blocs. Votre code doit gérer tout cela.

Les limites de taille de réponse existent. Même dans la plage de blocs, si un contrat populaire emet des milliers d'événements par bloc, votre réponse peut depasser la limite de charge utile du provider (150 Mo sur Alchemy). L'appel ne retourne pas de résultats partiels. Il echoue.

Les plages vides ne sont pas gratuites. Même s'il n'y a aucun log correspondant, le provider scanne quand même la plage de blocs. Cela compte contre vos unites de calcul.

Voici un utilitaire de pagination qui gere ces contraintes :

typescript
import type { PublicClient, Log, AbiEvent } from "viem";
 
async function fetchLogsInChunks<T extends AbiEvent>(
  client: PublicClient,
  params: {
    address: `0x${string}`;
    event: T;
    fromBlock: bigint;
    toBlock: bigint;
    maxBlockRange?: bigint;
  }
): Promise<Log<bigint, number, false, T, true>[]> {
  const { address, event, fromBlock, toBlock, maxBlockRange = 2000n } = params;
  const allLogs: Log<bigint, number, false, T, true>[] = [];
 
  let currentFrom = fromBlock;
 
  while (currentFrom <= toBlock) {
    const currentTo =
      currentFrom + maxBlockRange - 1n > toBlock
        ? toBlock
        : currentFrom + maxBlockRange - 1n;
 
    try {
      const logs = await client.getLogs({
        address,
        event,
        fromBlock: currentFrom,
        toBlock: currentTo,
      });
 
      allLogs.push(...logs);
      currentFrom = currentTo + 1n;
    } catch (error) {
      // Si la plage est trop large (trop de resultats), la diviser en deux
      if (isRangeTooLargeError(error) && currentTo > currentFrom) {
        const mid = currentFrom + (currentTo - currentFrom) / 2n;
        const firstHalf = await fetchLogsInChunks(client, {
          address,
          event,
          fromBlock: currentFrom,
          toBlock: mid,
          maxBlockRange,
        });
        const secondHalf = await fetchLogsInChunks(client, {
          address,
          event,
          fromBlock: mid + 1n,
          toBlock: currentTo,
          maxBlockRange,
        });
        allLogs.push(...firstHalf, ...secondHalf);
        currentFrom = currentTo + 1n;
      } else {
        throw error;
      }
    }
  }
 
  return allLogs;
}
 
function isRangeTooLargeError(error: unknown): boolean {
  const message = error instanceof Error ? error.message : String(error);
  return (
    message.includes("Log response size exceeded") ||
    message.includes("query returned more than") ||
    message.includes("exceed maximum block range")
  );
}

L'idee cle est la division binaire en cas d'echec. Si une plage de 2K blocs retourne trop de logs, divisez-la en deux plages de 1K. Si 1K est encore trop, divisez a nouveau. Cela s'adapte automatiquement aux contrats a forte activite sans que vous ayez besoin de connaitre la densite d'événements a l'avance.

BigInt va vous remettre a votre place#

Le type Number de JavaScript est un flottant 64 bits. Il peut representer des entiers jusqu'a 2^53 - 1 — environ 9 billiards. Ca semble beaucoup jusqu'a ce que vous realisiez qu'un montant de token de 1 ETH en wei est 1000000000000000000 — un nombre a 18 zeros. C'est 10^18, bien au-dela de Number.MAX_SAFE_INTEGER.

Si vous convertissez accidentellement un BigInt en Number quelque part dans votre pipeline — JSON.parse, un driver de base de donnees, une bibliotheque de logging — vous obtenez une perte de précision silencieuse. Le nombre a l'air a peu pres correct mais les derniers chiffres sont faux. Vous ne detecterez pas ca en test parce que vos montants de test sont petits.

Voici le bug que j'ai livre en production :

typescript
// LE BUG : Ca a l'air inoffensif, ca ne l'est pas
function formatTokenAmount(amount: bigint, decimals: number): string {
  return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
 
// Pour les petits montants ca fonctionne :
formatTokenAmount(1000000n, 6); // "1.0000" -- correct
 
// Pour les gros montants ca casse silencieusement :
formatTokenAmount(123456789012345678n, 18);
// Retourne "0.1235" -- FAUX, la precision est perdue
// Number(123456789012345678n) === 123456789012345680
// Les deux derniers chiffres ont ete arrondis par IEEE 754

La correction : ne jamais convertir en Number avant de diviser. Utilisez les utilitaires integres de viem, qui operent sur des chaines et des BigInts :

typescript
import { formatUnits, parseUnits } from "viem";
 
// Correct : opere sur BigInt, retourne une chaine
function formatTokenAmount(
  amount: bigint,
  decimals: number,
  displayDecimals: number = 4
): string {
  const formatted = formatUnits(amount, decimals);
 
  // formatUnits retourne la chaine en precision complete comme "0.123456789012345678"
  // Tronquer (pas arrondir) a la precision d'affichage desiree
  const [whole, fraction = ""] = formatted.split(".");
  const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
 
  return `${whole}.${truncated}`;
}
 
// Egalement crucial : utiliser parseUnits pour les saisies utilisateur, jamais parseFloat
function parseTokenInput(input: string, decimals: number): bigint {
  // parseUnits gere correctement la conversion chaine vers BigInt
  return parseUnits(input, decimals);
}

Remarquez que je tronque au lieu d'arrondir. C'est delibere. Dans un contexte financier, afficher "1.0001 ETH" quand la vraie valeur est "1.00009999..." est mieux que d'afficher "1.0001" quand la vraie valeur est "1.00005001..." et a été arrondie vers le haut. Les utilisateurs prennent des decisions basees sur les montants affiches. La troncature est le choix conservateur.

Un autre piege : JSON.stringify ne sait pas serialiser BigInt. Ca leve une exception. Chaque réponse de votre API qui inclut des montants de tokens a besoin d'une stratégie de serialisation. J'utilisé la conversion en chaine a la frontiere de l'API :

typescript
// Serialiseur de reponse API
function serializeForApi(data: Record<string, unknown>): string {
  return JSON.stringify(data, (_, value) =>
    typeof value === "bigint" ? value.toString() : value
  );
}

Stratégie de cache : quoi, combien de temps, et quand invalider#

Toutes les donnees on-chain n'ont pas les mêmes exigences de fraicheur. Voici la hierarchie que j'utilisé :

Mettre en cache indefiniment (immuable) :

  • Recus de transaction (une fois mines, ils ne changent pas)
  • Donnees de blocs finalises (hash du bloc, timestamp, liste des transactions)
  • Bytecode de contrat
  • Logs d'événements historiques provenant de blocs finalises

Mettre en cache pendant quelques minutes a quelques heures :

  • Metadonnees de token (nom, symbole, decimales) — techniquement immuable pour la plupart des tokens, mais les mises a jour de proxy peuvent changer l'implementation
  • Resolutions ENS — un TTL de 5 minutes fonctionne bien
  • Prix des tokens — depend de vos exigences de précision, 30 secondes a 5 minutes

Mettre en cache quelques secondes ou pas du tout :

  • Numero de bloc actuel
  • Soldes et nonce des comptes
  • Statut des transactions en attente
  • Logs d'événements non finalises (encore le problème des reorgs)

L'implementation n'a pas besoin d'être complexe. Un cache a deux niveaux avec LRU en mémoire et Redis couvre la plupart des cas :

typescript
import { LRUCache } from "lru-cache";
 
const memoryCache = new LRUCache<string, unknown>({
  max: 10_000,
  ttl: 1000 * 60, // 1 minute par defaut
});
 
type CacheTier = "immutable" | "short" | "volatile";
 
const TTL_MAP: Record<CacheTier, number> = {
  immutable: 1000 * 60 * 60 * 24, // 24 heures en memoire, permanent dans Redis
  short: 1000 * 60 * 5,            // 5 minutes
  volatile: 1000 * 15,             // 15 secondes
};
 
async function cachedRpcCall<T>(
  key: string,
  tier: CacheTier,
  fetcher: () => Promise<T>
): Promise<T> {
  // Verifier la memoire en premier
  const cached = memoryCache.get(key) as T | undefined;
  if (cached !== undefined) return cached;
 
  // Puis Redis (si vous en avez un)
  // const redisCached = await redis.get(key);
  // if (redisCached) { ... }
 
  const result = await fetcher();
  memoryCache.set(key, result, { ttl: TTL_MAP[tier] });
 
  return result;
}
 
// Utilisation :
const receipt = await cachedRpcCall(
  `receipt:${txHash}`,
  "immutable",
  () => client.getTransactionReceipt({ hash: txHash })
);

La leçon contre-intuitive : le plus gros gain de performance n'est pas la mise en cache des réponses RPC. C'est d'éviter completement les appels RPC. Chaque fois que vous etes sur le point d'appeler getBlock, demandez-vous : ai-je réellement besoin de donnees de la chaine maintenant, ou puis-je les deriver de donnees que j'ai déjà ? Puis-je ecouter les événements via WebSocket au lieu de faire du polling ? Puis-je regrouper plusieurs lectures en un seul multicall ?

TypeScript et les ABI de contrats : la bonne méthode#

Le système de types de viem, propulse par ABIType, fournit une inference de types complete de bout en bout, de l'ABI de votre contrat a votre code TypeScript. Mais seulement si vous le configurez correctement.

La mauvaise méthode :

typescript
// Pas d'inference de type — args est unknown[], le retour est unknown
const result = await client.readContract({
  address: "0x...",
  abi: JSON.parse(abiString), // parse au runtime = pas d'info de type
  functionName: "balanceOf",
  args: ["0x..."],
});

La bonne méthode :

typescript
// Definir l'ABI avec as const pour une inference de type complete
const erc20Abi = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "balance", type: "uint256" }],
  },
  {
    name: "transfer",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" },
    ],
    outputs: [{ name: "success", type: "bool" }],
  },
] as const;
 
// Maintenant TypeScript sait :
// - functionName autocomplete vers "balanceOf" | "transfer"
// - args pour balanceOf est [address: `0x${string}`]
// - le type de retour pour balanceOf est bigint
const balance = await client.readContract({
  address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  abi: erc20Abi,
  functionName: "balanceOf",
  args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- entierement type

L'assertion as const est ce qui fait fonctionner le tout. Sans elle, TypeScript elargit le type de l'ABI en { name: string, type: string, ... }[] et toute la machinerie d'inference s'effondre. C'est l'erreur la plus courante que je vois dans les codebases TypeScript Web3.

Pour les projets plus importants, utilisez @wagmi/cli pour générer des bindings de contrat types directement depuis votre projet Foundry ou Hardhat. Il lit vos ABI compilees et produit des fichiers TypeScript avec les assertions as const déjà appliquees. Pas de copie manuelle d'ABI, pas de derive de types.

La vérité qui derange#

Les donnees blockchain sont un problème de système distribue qui se fait passer pour un problème de base de donnees. Des que vous les traitez comme "juste une autre API", vous commencez a accumuler des bugs invisibles en développement et intermittents en production.

L'outillage s'est considerablement ameliore. Viem est une amelioration massive par rapport a ethers.js en termes de sécurité des types et d'experience développeur. Ponder et Envio ont rendu l'indexation personnalisee accessible. Mais les defis fondamentaux — reorgs, rate limits, encodage, finalite — sont au niveau du protocole. Aucune bibliotheque ne les abstrait completement.

Construisez en partant du principe que votre RPC vous mentira, que vos blocs se reorganiseront, que vos nombres deborderont et que votre cache servira des donnees obsoletes. Puis gerez chaque cas explicitement.

C'est ca, les donnees on-chain en production.

Articles similaires