Journal
9 min de lecture

Rejouer la chaîne en local : le fork qui a rendu les simulations de mon bot abordables

Un bot qui choisit ses combats en simulant le moteur de bataille du jeu a besoin de centaines de milliers de lectures on-chain. Le tier RPC gratuit ne suit pas, et je refusais de payer. La solution : forker la chaîne sur ma propre machine — et le bug mémoire qui m'a appris qu'élégant n'est pas la même chose que correct.

Dans le post précédent, j'expliquais comment Luchamon Bot choisit un adversaire : au lieu de deviner, il rejoue le moteur de bataille du jeu lui-même sur de nombreuses graines aléatoires et estime une probabilité de victoire. Ça marche. Et c'est obscènement cher dans une devise bien précise — les lectures on-chain.

Ce post parle de l'addition, et de mon refus de la payer de la façon facile.

Les chiffres qui ne rentrent pas

Chaque lecture est un appel à un endpoint RPC public. Voici ce qu'une décision coûte vraiment, en appels :

OpérationLectures
Choisir un adversaire (par combat)~330
Analyse de build approfondie (un Luchamon)~10 000–25 000
La même analyse sur les 49 Luchamons500 000–1 200 000

C'est cette dernière ligne qui a cassé le design. Mon RPC est un plan gratuit avec un budget en compute-units qui revient en pratique à environ douze eth_call par seconde, même après avoir groupé des dizaines d'appels dans chaque requête HTTP. Fais la division : une analyse sur tout le roster à douze lectures par seconde, c'est un job de plusieurs jours. Il ne tournait tout simplement jamais. La chose la plus utile que le bot pouvait calculer était la seule qu'il ne pouvait pas se payer.

J'avais déjà essoré le côté client — batching JSON-RPC (cinquante appels par requête HTTP), un throttle leaky-bucket partagé pour que les tâches concurrentes ne fassent pas exploser le quota, l'échantillonnage apparié pour avoir besoin de moins de graines, un cache disque. Chacun a aidé. Aucun n'a bougé le plafond, parce que le plafond, ce n'est pas mon code. C'est l'aller-retour vers le serveur de quelqu'un d'autre, et le rate limit qui attend à l'autre bout.

Les deux échappatoires que je n'ai pas prises

Il y a deux sorties évidentes, et je les ai rejetées exprès.

Payer un plan RPC plus gros. Un tier payant relève le rate limit. Il transforme aussi un loisir en abonnement, et il ne touche pas l'autre moitié du problème — chaque appel traverse encore le réseau et attend ~80 ms une réponse. Je ne voulais pas payer un forfait mensuel pour accélérer mon propre jouet.

Faire tourner un nœud Base complet. Contrôle total, pas de rate limit, pas de saut réseau. Aussi des centaines de gigaoctets de disque et une synchro initiale de plusieurs jours, pour servir une charge qui ne lit jamais qu'une poignée de contrats chauds. Un marteau-pilon pour un problème qui n'est pas si gros.

Les deux traitent le symptôme — pas assez de débit vers une chaîne distante — au lieu de la vraie intuition cachée dans le problème.

L'intuition : le moteur est déterministe et public

La logique de combat que je martèle est une fonction view. Elle lit du state on-chain et calcule un résultat ; elle ne change rien. Elle est vérifiée et publique. Et elle est déterministe — pour le même state et la même graine, elle renvoie le même vainqueur à chaque fois.

Une fonction pure du state, que je peux lire gratuitement… n'a pas à tourner sur le serveur de quelqu'un d'autre. Elle peut tourner sur le mien, si j'apporte le state avec elle. C'est exactement ce qu'est un fork : Anvil (le nœud local de Foundry) démarre une chaîne qui tire paresseusement le vrai state mainnet à la demande et le sert depuis la mémoire locale. La première fois que ma simulation touche le storage d'un contrat, Anvil va chercher ce slot une fois sur le vrai RPC et le met en cache ; toutes les lectures suivantes sont un appel local sous la milliseconde. Je n'approxime pas la chaîne — je fais tourner le vrai bytecode contre le vrai state, juste sans le réseau dans la boucle.

Deux clients, un interrupteur

L'implémentation est petite et délibérément ennuyeuse. Le bot tient deux clients viem :

// Les writes et les lectures canoniques vont toujours au vrai RPC Base.
const publicClient = createPublicClient({ chain: base, transport: readTransport });

// Les simulations vont au fork local — ou retombent de façon transparente
// sur le vrai RPC quand le fork est off, pour que les métriques restent comparables.
const simClient =
  SIM_BACKEND === "anvil"
    ? createPublicClient({ transport: http(anvilUrl, { batch: { batchSize: 50, wait: 16 } }) })
    : publicClient;

Tout ce qui écrit — choisir réellement l'adversaire, envoyer le combat — reste sur la vraie chaîne via publicClient / walletClient. Tout ce qui simule passe par simClient. Un seul interrupteur SIM_BACKEND=anvil|rpc bascule le chemin de simulation, et quand il est off, simClient est littéralement publicClient — donc le chemin de code est identique et les chiffres restent honnêtes, fork ou pas. Le fork lui-même ne quitte jamais le réseau Docker interne ; rien n'expose un nœud local capable de falsifier le state.

Estimation vs mesure

Voici la partie où je veux être honnête, parce que le calcul de coin de table et le chronomètre n'étaient pas d'accord.

Sur le papier, le speedup paraît énorme : un appel local est sous la milliseconde, un distant est à ~80 ms plus le rate-limiting. C'est un ratio de latence brute dans la fourchette 50–200×, et c'est le chiffre que j'avais écrit dans le doc de design. J'ai donc construit un benchmark pour le prouver avant de câbler le fork où que ce soit — même simulation, Alchemy contre le fork, chrono des deux.

Le speedup mesuré est tombé autour de 24×.

Pas 100×. Vingt-quatre. Et l'écart est le point intéressant : mon client RPC groupe déjà cinquante appels dans une requête HTTP, donc le chemin distant ne payait jamais la pleine latence par appel — le batching l'avait silencieusement amortie. L'avantage du fork est réel mais plus petit que ne le suggère le calcul de latence naïf, parce que j'avais déjà fait les optimisations pas chères. La leçon que je réapprends sans cesse : mesure la chose que tu livres vraiment, pas celle que ton modèle mental prédit.

24×, ça reste transformateur là où ça compte :

  • L'analyse de build approfondie : 30–90 minutes → 2–5 minutes.
  • Une passe de calibration : ~40 minutes → moins d'une minute.
  • L'analyse sur tout le roster qui ne tournait jamais : faisable, autour de dix minutes pour les 49.

La capacité que je ne pouvais pas me payer est devenue une pause café.

Le correctif élégant qui fuyait

Maintenant le bug, parce que c'est la meilleure partie.

Un fork tient son state en mémoire. À mesure que mes simulations touchent plus de la chaîne, cette mémoire grossit, donc il faut la vider périodiquement. Foundry livre un outil tout propre pour exactement ça : anvil_reset, un appel RPC custom qui jette le cache et re-snapshote le dernier block en quelques secondes. Je l'ai câblé pour tourner entre les tâches. Propre, rapide, idiomatique. Réglé.

Sauf que ce n'était pas réglé. Les durées de tâches ont commencé à grimper — et pas qu'un peu. Un job qui prenait 15 minutes en début de session en prenait 1 h 50 plus tard dans la même session. Même travail, huit fois plus lent, uniquement en fonction de depuis combien de temps le fork était en vie.

Le cache n'était pas le problème ; il est minuscule. Le problème, c'était tout autour du cache. Cinq millions et plus d'eth_call par tâche laissent une traînée d'allocations transitoires, et l'allocateur d'Anvil garde cette mémoire au lieu de la rendre à l'OS. anvil_reset vide le cache mais ne rend pas la mémoire — donc le process gonfle, et la performance se dégrade linéairement avec l'uptime. Le reset in-process élégant traitait la mauvaise couche.

La seule chose qui rend vraiment la mémoire au système d'exploitation, c'est terminer le process. Le correctif est donc brutal : un petit superviseur en sidecar qui possède le socket Docker et redémarre tout le conteneur du fork quand la file est au repos, en attendant que le nouveau fork réponde avant de laisser le travail reprendre. Ça coûte ~10 secondes au lieu des ~1–3 du reset RPC — totalement invisible entre des batches qui tournent des minutes ou des heures. Moins malin, strictement correct. Je prends correct.

La seule règle que je ne plierai pas : pas de dérive silencieuse

Il y a un danger sourd à faire tourner une copie locale d'un contrat : et si le jeu met à jour sa logique de combat et que mon fork simule désormais les règles de la semaine dernière ? Chaque recommandation serait confiante, invisiblement fausse.

Alors le fork gagne sa confiance à chaque boot. J'ai enregistré une empreinte — le hash du bytecode du contrat de combat — au moment où j'ai validé la simulation contre la réalité. Au démarrage, le bot lit le bytecode depuis le fork et compare :

const code = await simClient.getCode({ address: BATTLE_LOGIC });
if (keccak256(code) !== PINNED_BYTECODE_HASH) {
  // Avertissement bruyant. Pas de fallback automatique — l'opérateur décide.
}

Si les hashes ne correspondent pas, le contrat a changé sous moi, et le bot le dit — fort — et refuse de faire semblant. Surtout, il n'y a pas de failover automatique vers le vrai RPC. Un repli silencieux masquerait exactement l'événement que j'ai le plus besoin de connaître. Quand le sol bouge, je veux une alarme, pas un haussement d'épaules.

Ce que je dirais à mon moi passé

Trois choses, dans l'ordre de ce qu'elles m'ont coûté à apprendre :

  1. Le goulot, c'est rarement ton code. J'ai optimisé le batching et le cache pendant des semaines ; le vrai gain a été de supprimer le réseau. Trouve le mur avant de poncer les lames du parquet.
  2. Benchmark avant de croire. Mon estimation de 50–200× était fausse d'un facteur 4–8× parce que j'avais oublié mon propre batching. Le chronomètre est la seule autorité.
  3. Élégant et correct sont deux axes différents. anvil_reset était la belle réponse et la mauvaise. Un redémarrage de process est moche et il marche. Livre celui qui marche.

Le bot simule maintenant tout un roster contre le vrai moteur du jeu le temps de faire un café, sur du matériel que je possédais déjà, gratuitement. Pas parce que j'ai trouvé une astuce maline — mais parce que j'ai arrêté de me battre contre la chaîne et que j'en ai ramené une copie à la maison.

— Pato