Journal
8 min read

Replaying the chain locally: the fork that made my bot's simulations affordable

A bot that picks fights by simulating the game's battle engine needs hundreds of thousands of on-chain reads. The free RPC tier can't keep up, and I refused to pay for it. The fix: fork the chain onto my own machine — and the memory bug that taught me elegant isn't the same as correct.

In the previous post I described how Luchamon Bot picks an opponent: instead of guessing, it replays the game's own battle engine over many random seeds and estimates a win probability. It works. It's also obscenely expensive in one specific currency — on-chain reads.

This post is about the bill, and how I refused to pay it the easy way.

The numbers that don't fit

Every read is a call to a public RPC endpoint. Here's what one decision actually costs in calls:

OperationReads
Pick one opponent (per fight)~330
Deep build analysis (one Luchamon)~10,000–25,000
Same analysis across all 49 Luchamons500,000–1,200,000

That last row is the one that broke the design. My RPC is a free-tier plan with a compute-units budget that works out to roughly twelve eth_calls per second in practice, even after I batch dozens of calls into each HTTP request. Do the division: a roster-wide analysis at twelve reads a second is a multi-day job. It simply never ran. The most useful thing the bot could compute was the one thing it couldn't afford to.

I'd already squeezed the client side hard — JSON-RPC batching (fifty calls per HTTP request), a shared leaky-bucket throttle so concurrent tasks don't burst the quota, paired sampling to need fewer seeds, a disk cache. Each helped. None of them moved the ceiling, because the ceiling isn't my code. It's the round trip to someone else's server and the rate limit waiting at the other end.

The two escape hatches I didn't take

There are two obvious ways out, and I rejected both on purpose.

Pay for a bigger RPC plan. A paid tier lifts the rate limit. It also turns a hobby into a subscription, and it doesn't touch the other half of the problem — every call still crosses the network and waits ~80 ms for an answer. I didn't want to pay a monthly fee to make my own toy faster.

Run a full Base node. Total control, no rate limit, no network hop. Also hundreds of gigabytes of disk and a multi-day initial sync, to serve a workload that only ever reads a few hundred hot contracts. A sledgehammer for a problem that isn't that big.

Both treat the symptom — not enough throughput to a remote chain — instead of the actual insight hiding in the problem.

The insight: the engine is deterministic and public

The battle logic I'm hammering is a view function. It reads on-chain state and computes a result; it changes nothing. It's verified and public. And it's deterministic — given the same state and the same seed, it returns the same winner every time.

A pure function of state that I can read for free… doesn't have to run on someone else's server. It can run on mine, if I bring the state with it. That's exactly what a fork is: Anvil (Foundry's local node) boots a chain that lazily pulls real mainnet state on demand and serves it from local memory. The first time my simulation touches a contract's storage, Anvil fetches that slot once from the real RPC and caches it; every read after that is a sub-millisecond local call. I'm not approximating the chain — I'm running the real bytecode against real state, just without the network in the loop.

Two clients, one toggle

The implementation is small and deliberately boring. The bot holds two viem clients:

// Writes and the canonical reads still go to the real Base RPC.
const publicClient = createPublicClient({ chain: base, transport: readTransport });

// Simulations go to the local fork — or transparently fall back to the
// real RPC when the fork is off, so metrics stay comparable either way.
const simClient =
  SIM_BACKEND === "anvil"
    ? createPublicClient({ transport: http(anvilUrl, { batch: { batchSize: 50, wait: 16 } }) })
    : publicClient;

Everything that writes — actually picking the opponent, sending the fight — stays on the real chain through publicClient / walletClient. Everything that simulates goes through simClient. A single SIM_BACKEND=anvil|rpc switch flips the simulation path, and when it's off, simClient is literally publicClient — so the code path is identical and the numbers stay honest whether or not the fork is in play. The fork itself never leaves the internal Docker network; nothing exposes a local node that can fake state.

Estimate vs. measurement

Here's the part I want to be honest about, because the back-of-the-envelope and the stopwatch disagreed.

On paper, the speedup looks enormous: a local call is under a millisecond, a remote one is ~80 ms plus rate-limiting. That's a raw latency ratio in the 50–200× range, and that's the number I wrote in the design doc. So I built a benchmark to prove it before wiring the fork into anything — same simulation, Alchemy versus the fork, wall-clock both.

The measured speedup came in around 24×.

Not 100×. Twenty-four. And the gap is the interesting bit: my RPC client already batches fifty calls into one HTTP request, so the remote path was never paying full per-call latency — the batching had quietly amortized most of it. The fork's advantage is real but smaller than the naive latency math suggests, because I'd already done the cheap optimizations. The lesson I keep relearning: measure the thing you actually ship, not the thing your mental model predicts.

24× is still transformative where it counts:

  • The deep build analysis: 30–90 minutes → 2–5 minutes.
  • A calibration run: ~40 minutes → under a minute.
  • The roster-wide analysis that never ran: feasible, around ten minutes for all 49.

The capability I couldn't afford became a coffee break.

The elegant fix that leaked

Now the bug, because it's the best part.

A fork holds state in memory. As my simulations touch more of the chain, that memory grows, so it needs periodic flushing. Foundry ships a tidy tool for exactly this: anvil_reset, a custom RPC call that drops the cache and re-snapshots the latest block in a couple of seconds. I wired it to run between tasks. Clean, fast, idiomatic. Done.

Except it wasn't done. Task durations started creeping up — and not a little. One job that took 15 minutes early in a session was taking 1 hour 50 later in the same session. Same work, eight times slower, purely as a function of how long the fork had been alive.

The cache wasn't the problem; it's tiny. The problem was everything around the cache. Five million-plus eth_calls per task leave a trail of transient allocations, and Anvil's allocator holds onto that memory rather than returning it to the OS. anvil_reset flushes the cache but doesn't hand the memory back — so the process bloats, and performance degrades linearly with uptime. The elegant in-process reset was treating the wrong layer.

The only thing that actually returns memory to the operating system is ending the process. So the fix is blunt: a small supervisor sidecar that owns the Docker socket and restarts the entire fork container when the queue is idle, waiting until the new fork answers before letting work resume. It costs ~10 seconds instead of the ~1–3 of the RPC reset — completely invisible between batches that run for minutes or hours. Less clever, strictly correct. I'll take correct.

The one rule I won't bend: no silent drift

There's a quiet danger in running a local copy of a contract: what if the game upgrades its battle logic and my fork is now simulating last week's rules? Every recommendation would be confidently, invisibly wrong.

So the fork earns its trust at every boot. I recorded a fingerprint — the hash of the battle contract's bytecode — back when I validated the simulation against reality. On startup, the bot reads the bytecode from the fork and compares:

const code = await simClient.getCode({ address: BATTLE_LOGIC });
if (keccak256(code) !== PINNED_BYTECODE_HASH) {
  // Loud warning. No automatic fallback — the operator decides.
}

If the hashes don't match, the contract changed under me, and the bot says so — loudly — and refuses to pretend. Crucially there's no automatic failover to the real RPC. A silent fallback would paper over exactly the event I most need to know about. When the ground shifts, I want an alarm, not a shrug.

What I'd tell my past self

Three things, in order of how much they cost me to learn:

  1. The bottleneck is rarely your code. I optimized batching and caching for weeks; the real win was deleting the network. Find the wall before you sand the floorboards.
  2. Benchmark before you believe. My 50–200× estimate was off by 4–8× because I forgot my own batching. The stopwatch is the only authority.
  3. Elegant and correct are different axes. anvil_reset was the beautiful answer and the wrong one. A process restart is ugly and it works. Ship the one that works.

The bot now simulates a whole roster against the game's real engine in the time it takes to make coffee, on hardware I already owned, for free. Not because I found a clever trick — but because I stopped fighting the chain and brought a copy of it home.

— Pato