The Day I Realized Mainnet Is a Terrible Classroom
A few weeks into this project, I caught myself doing something embarrassing. I had a brand new arbitrage path I wanted to verify, I was tired, and instead of writing a test, I pointed the bot at mainnet, hit run, and watched the logs scroll. Every iteration cost me real lamports in fees, every bad signature was a public record on a forever-ledger, and every WebSocket hiccup forced me to start the experiment over because the state I was reasoning about had already moved on.
It's a little like trying to learn how to swing a bat by stepping into a Major League batter's box during a real game. The feedback is real, sure. It's also expensive, slow, and you only get one swing per pitch. The right place to learn is the batting cage: same physics, no scoreboard, and you control the pitch speed.
This post is about how I built that batting cage for my MEV bot — the layered testing strategy that finally let me iterate in milliseconds on my laptop instead of minutes on mainnet.
Why "Just Use Devnet" Doesn't Save You
The natural first instinct is to push everything to devnet and call it done. Devnet is free, it speaks the same JSON-RPC, and it doesn't burn real SOL. For a hello-world program, devnet is fine. For an MEV bot, it's misleading in two specific ways.
First, devnet doesn't have the liquidity, the pool composition, or the swap volume that the bot is actually hunting. Your route-finding algorithm will happily report a hundred opportunities a second on devnet because the pools are toys. None of them mean anything. You learn nothing about whether the bot is correct on real state.
Second, devnet round-trips are still network round-trips. A Solana testing guide I read while researching this puts the alternative bluntly: manual testing on mainnet is expensive, and manual testing on devnet is slow and "often incomplete." That phrase stuck with me. Devnet isn't free; it's just paying in time instead of money. And in MEV, time is the more expensive currency.
There's a workflow rule of thumb that floats around in the official Solana docs and various community guides: spend roughly 80% of testing time on the local validator and 20% on a public test network. The phrasing varies, but the spirit is consistent. Devnet is the final sanity check, not the workbench.
The Mental Model: Five Layers, Not One Test
The biggest unlock for me was giving up on the idea of a single "test" and instead thinking in layers. Each layer is faster than the one below it, costs nothing different to run, and isolates a different class of bug.
A useful way to picture it: a hitter doesn't just take batting practice on a live field. They start with a tee, move to soft toss, then to a pitching machine, then to live BP, and only then to a real game. Each step trades realism for repeatability, and each step is designed to surface a specific kind of mistake before it costs anything.
My MEV bot's testing pyramid looks roughly like this:
- Layer 1 — Pure math, no I/O. The arbitrage profit calculation, the route finder, the fee math, the slippage formulas. These are pure functions. They don't touch the network. They run in microseconds.
- Layer 2 — Async logic with mocked I/O. The WebSocket stream processor, the channel plumbing between tasks, the event handlers. No real socket, just bytes I control.
- Layer 3 — In-process Solana VM. A whole Solana runtime running inside my test binary. No validator process, no RPC, no JSON. Just direct function calls into the VM.
- Layer 4 — Local validator. A full single-node Solana cluster on my laptop. Real RPC at
http://127.0.0.1:8899, real account model, optionally cloned mainnet accounts for realistic state. - Layer 5 — Public test network. The final sanity check before mainnet. Used sparingly.
Notice what's missing: a layer called "mainnet." Mainnet isn't a testing layer at all in this model. Mainnet is production. You verify on mainnet by watching what happens, not by hitting run and hoping.
Layer 1: Treating Math as Math
The most embarrassing class of MEV bug, in retrospect, is the math bug. You computed a profit, the number was positive, you fired the transaction, and the transaction reverted because the math was wrong. The math doesn't need a blockchain to be wrong. It's wrong on its own, with no network in sight.
Which means the math doesn't need a blockchain to be tested either.
In Rust, this is the most boring possible setup: a #[test] function, a few inputs, an assert_eq!. The AMM curve math, the fee deduction, the slippage projection — every one of these is a pure function of pool reserves and input amount. You feed it numbers, you check the output. No tokio, no async, no sockets, no signers, nothing.
The shape of a test like this is almost trivially simple:
#[test]
fn computes_expected_output_for_constant_product_pool() {
let reserve_in = 1_000_000u128;
let reserve_out = 2_000_000u128;
let amount_in = 1_000u128;
let fee_bps = 30u16;
let out = constant_product_quote(reserve_in, reserve_out, amount_in, fee_bps);
assert!(out > 0);
assert!(out < amount_in * 2);
}
That's it. No fanfare. The compiler runs the test in a few milliseconds, and any regression in the formula is caught the next time I touch the file.
The reason this matters isn't the test itself. It's what it stops me from doing. Without this layer, every change to the profit calculation gets validated against mainnet — slowly, expensively, and with all the noise of real network state confusing the signal. With this layer, the math is locked down before any bytes leave my laptop.
A real-world note from one developer I read while researching this: he built an entire MEV bot in Rust — by his account, roughly ten thousand lines of client code plus about five hundred lines of Solidity for execution — and he writes openly that the bot was unprofitable. What he kept doing, though, was logging every attempt into an SQLite database so he could analyze patterns later. The math layer is essentially that same instinct, applied earlier: don't wait until production to find out your numbers are wrong.
Layer 2: Faking the Socket Without Faking the Logic
Here's where things get interesting. The bot's event loop reads from a WebSocket stream, deserializes the messages, decides whether each event represents an opportunity, and pushes work into downstream channels. There's a lot of logic in that pipeline, and almost none of it depends on the WebSocket actually being a WebSocket.
The trick is to write the function to accept any source of bytes, not specifically a WebSocket. Tokio's own testing documentation lays this out: design functions to take generic AsyncRead + AsyncWrite trait bounds, and you can swap in mock streams in tests.
The pattern looks roughly like this:
async fn handle_stream<R, W>(reader: R, mut writer: W) -> std::io::Result<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
// ... real logic, processing bytes from `reader`,
// writing responses into `writer` ...
Ok(())
}
In production, reader and writer are the two halves of a real WebSocket. In tests, they're whatever I want them to be — a canned script of bytes from tokio_test::io::Builder, or a fixture loaded from a file of recorded mainnet messages.
#[tokio::test]
async fn parses_swap_event_correctly() {
let reader = tokio_test::io::Builder::new()
.read(include_bytes!("fixtures/sample_swap_event.json"))
.build();
let writer = tokio_test::io::Builder::new().build();
let result = handle_stream(reader, writer).await;
assert!(result.is_ok());
}
For more sophisticated cases — multi-message conversations, server-side message ordering, handshake-style negotiation — there are dedicated crates that spin up an in-process mock WebSocket server. Two I came across while researching are wiremocket and ws-mock, both directly inspired by the wiremock-rs library that anyone who's tested HTTP code in Rust will recognize. The basic pattern is the same: start a MockServer, register expected messages and canned responses, point the client at the mock's URI, and let the test exercise the real connection logic against a controllable peer.
The other piece that matters here is time. Real MEV code is full of intervals, timeouts, retries, backoffs — all of which are nightmares to test if the test has to actually wait. Tokio has a fix that I find almost suspicious in how nice it is: #[tokio::test(start_paused = true)]. The runtime starts with the clock frozen. When code asks for a sleep, the runtime advances the virtual clock immediately to satisfy it. A test that exercises an hour of interval ticks runs in microseconds, deterministically, with no real waiting.
That one annotation is the difference between writing tests for timing logic and giving up and shipping it.
Layer 3: A Whole Solana VM Inside Your Test Binary
This is the layer that, when I finally adopted it, made me feel like I'd been doing things wrong the entire time. The idea: instead of running a separate validator process and talking to it over RPC, embed the Solana virtual machine directly into the test binary. No process boundary, no JSON serialization, no network — just a function call into a VM that knows how to execute Solana transactions.
There's a small history here. The first widely-used tool for this was solana-program-test, with its BanksClient interface, which let Rust tests construct and submit transactions to an in-process bank. Then came bankrun, a Node.js wrapper that made the same capability available to TypeScript devs. Bankrun was fast and powerful, but it inherited limitations from the underlying framework. As of early 2025, bankrun has been deprecated in favor of LiteSVM, which the project's own documentation describes as "a fast and lightweight library for testing Solana programs" that works by "creating an in-process Solana VM optimized for program developers."
The LiteSVM docs are direct about the trade-offs: the full local validator is "slow, unwieldy," and even bankrun, while "reasonably fast and powerful," inherits constraints from its lower layer. LiteSVM, by contrast, is "much faster to run and compile than alternatives."
What this means in practice: a test that constructs a transaction, executes it through the SVM, and asserts on the resulting account state runs in single-digit milliseconds. Fine-grained control over accounts, slots, and sysvars means I can set up bizarre edge cases — a pool with exactly one lamport of liquidity, a slot at a specific height, a clock value far in the future — that would be either impossible or hideously slow to engineer on a real network.
This layer is where I do most of my integration testing now. Profit math is locked down at Layer 1. Stream parsing is locked down at Layer 2. Layer 3 is where I verify that my bot, given a particular synthetic onchain state, constructs a transaction whose simulation through the SVM produces the expected balance changes. If the simulation says I made a tiny amount of profit on this fabricated arbitrage path, then my bot's math agreed with the runtime's math — and that's a powerful guarantee to have before I ever touch a real network.
Layer 4: The Local Validator With a Twist
When the in-process VM isn't enough — usually because I need to exercise the bot against the real RPC interface, or test something that depends on the validator's actual gossip behavior, or run an end-to-end smoke test of the production code path — I fall back to the local validator.
The Solana toolchain ships solana-test-validator, a full single-node cluster you can run on your laptop. It exposes RPC at http://127.0.0.1:8899 by default, has infinite tokens via airdrop, and lets you deploy programs locally without the friction of a real network. The official Solana docs highlight a few flags that make it actually pleasant to work with: --reset wipes the ledger back to genesis between runs, --quiet shuts off the firehose of log output, and --bpf-program preloads a compiled program at a chosen address.
The killer feature, though, is --clone. With it, you can pull specific account states from mainnet into your local validator at startup:
solana-test-validator \
--clone <POOL_ADDRESS> \
--clone <TOKEN_MINT_ADDRESS> \
--url <UPSTREAM_RPC> \
--reset
The result is a local cluster that's mostly empty but contains exactly the mainnet accounts you care about, frozen at the moment you cloned them. Your bot can then talk to this local validator over real RPC, with real state for the relevant pools, with no risk of actually submitting anything to mainnet and no fees.
This is the highest-fidelity layer I use regularly. It's also the slowest — the validator takes a few seconds to start, transactions take their normal cluster slot time to confirm, and the whole loop is noticeably heavier than the in-process SVM. But for catching the last class of bug — "my bot works perfectly when I call functions directly, but something about going through the real RPC client breaks it" — there's no substitute.
Why Local Simulation Is Worth the Engineering Effort
If you've never benchmarked local simulation against an external RPC, the numbers can feel abstract. Let me anchor them.
One of the clearest comparisons I've seen comes from Pawel Urbanek's writeup on simulating MEV arbitrage with REVM, Anvil, and Alloy. The setting is Ethereum, not Solana — REVM is the Rust implementation of the EVM, not the SVM — but the methodology is the methodology, and the gap is the gap. He ran 100 simulations through several configurations and measured both RPC call count and wall-clock time.
The baseline approach — naive eth_call against an external RPC — took roughly 4,400 milliseconds for the 100 simulations. The most optimized approach — REVM with a custom quoter and cached bytecode — took about 400 milliseconds against the same RPC, while reducing RPC calls from 100 down to 10. Against a local node, the same optimized approach ran in roughly 20 milliseconds.
That's the headline. A roughly 10x reduction in RPC calls, a roughly 10x reduction in wall-clock time against the external RPC, and another order of magnitude beyond that when you run against your own local node. For a bot that needs to evaluate hundreds of candidate paths per slot, this isn't an engineering preference. It's the difference between a system that can keep up and one that can't.
A separate benchmarking writeup from another MEV practitioner reports that a paid RPC subscription introduces roughly a quarter-second of lag per call, while a local full node responds near-instantaneously. A quarter second sounds trivial until you realize that's longer than a Solana slot. The opportunity you saw is gone by the time the response comes back. You weren't slow because the bot was slow. You were slow because you were standing in line for someone else's database.
The local validator and the in-process VM aren't just convenient for testing. They're also the staging ground where you discover that your production architecture has to look the same way: as close to the bare metal as you can get, with the network removed from every hot path that doesn't strictly need it.
What to Test, and What to Stop Testing
A layered testing strategy is only useful if you actually distribute the testing across the layers. The failure mode I've watched myself fall into is writing one heavyweight integration test for every change, because that test gives the most confidence per assertion. It also takes the longest to write, the longest to run, and breaks for the largest number of unrelated reasons.
The rule I now try to enforce on myself, more or less in this order:
- If the bug is in a pure function, write a Layer 1 test. No exceptions. These are so cheap that there is no excuse not to.
- If the bug is in async plumbing — channel ordering, event dispatch, timeout behavior — write a Layer 2 test with mocked I/O and paused time. The annoyance of setting up a mock once is repaid every time the test runs in microseconds.
- If the bug is in how the bot interacts with Solana's account model and transaction execution, write a Layer 3 test against the in-process VM. This is where I now spend most of my testing effort.
- If the bug is in the RPC client, the connection lifecycle, the actual wire-level interaction, escalate to Layer 4 and the local validator.
- Layer 5 — the public test network — is reserved for the absolute final pass. Cloning mainnet state into the local validator covers most of what people imagine devnet does for them, faster and with more control.
The other half of this discipline is knowing when to stop testing. Tests of trivial getters, tests that pin down implementation details that should be free to change, tests that exist because the author felt guilty rather than because they catch real bugs — these are net negative. They slow the suite, they break for the wrong reasons, and they fool you into thinking you have coverage when what you actually have is noise.
What I Stopped Doing
A short list of habits this approach replaced, mostly because I want to remember not to slip back into them:
- I stopped using mainnet as a debugger. Mainnet is a courtroom, not a lab.
- I stopped using devnet as a substitute for real testing. Devnet is a stage, not a classroom.
- I stopped writing tests that needed the network to pass. If the test needs the internet, it's not really a test of my code.
- I stopped accepting flaky tests. A test that fails one run in twenty is worse than no test; it teaches you to ignore failures.
- I stopped writing one giant end-to-end test for every change. Layered tests catch bugs faster and tell you specifically where the bug is.
None of this is novel. It's the same testing pyramid every backend engineer learns, applied to a domain where the cost of skipping it is unusually visible. The difference with MEV is that the production environment punishes mistakes immediately and publicly, which makes the case for thorough offline testing self-evident. The hard part isn't being convinced. The hard part is sitting down and building the layers when the tempting alternative is to just run the bot and see what happens.
Looking Forward
The layered model is settling in. Layer 1 and Layer 2 are essentially "done" in the sense that I have a habit and a pattern now. Layer 3 is where I'm investing the most engineering time — the in-process VM is fast enough that I can write tests for state transitions I would never have attempted to set up on a real network. Layer 4 is sitting in my back pocket for the cases where nothing else will do.
What I'm still figuring out is how to feed realistic state into these layers automatically. Cloning a handful of mainnet accounts by hand works for ad-hoc debugging, but at some point I'd like to have a job that snapshots a representative set of pools every few hours and uses those snapshots as the fixture for the next test run. That's not built yet. It's just where the next interesting problem seems to be.
For now, the bot is iterating in milliseconds on my laptop. Mainnet is no longer the classroom. That alone is worth the engineering it took to get here.
Key Takeaways
- Mainnet is production, not a testbed. Treating it as a debugger costs real money and corrupts the feedback loop you need to learn from.
- Devnet is the final sanity check, not the workbench. A common rule of thumb is roughly 80% of testing time spent on local environments and 20% on the public test network.
- Layer your tests. Pure math at Layer 1, mocked async I/O at Layer 2, in-process Solana VM at Layer 3, local validator at Layer 4, public test network last.
- The in-process VM is the unlock. LiteSVM-style frameworks let you run real Solana execution inside your test binary, with single-digit-millisecond feedback loops and no validator process.
- Local simulation isn't just faster; it's the only way to keep up. Benchmarks from EVM-side practitioners show local execution beating external RPCs by roughly an order of magnitude in both call count and latency, and the same gap exists on Solana.
Disclaimer
This article is for informational and educational purposes only and does not constitute financial, investment, legal, or professional advice. Content is produced independently and supported by advertising revenue. While we strive for accuracy, this article may contain unintentional errors or outdated information. Readers should independently verify all facts and data before making decisions. Company names and trademarks are referenced for analysis purposes under fair use principles. Always consult qualified professionals before making financial or legal decisions.