Mainnet fork: why local tests don't catch failures
A local stand comes up in seconds: clean chain, predictable balances, external contracts respond with stubs. Smart contract testing under such conditions passes green and creates false confidence: the bottleneck of the test loop is not code but network state.
Liquidity pools, oracle rates, third-party accounts with unexpected permissions, pauses and limits on external calls — none of this exists on a simplified stand.
A mainnet fork removes this limitation. We copy network state at a selected block — along with balances, granted permissions, deployed contracts and accumulated history — and deploy our code on top of it.
A transaction follows the same paths as in the public network: the same pools, the same market depth, the same limits on call size.
Production failures come precisely from this area. A trade executes at a tenth of expected volume and hits slippage; an external call reverts because a neighboring contract expired; wallet validation behaves differently due to the sent transaction counter.
On a local stand, such scenarios cannot be reproduced in principle — there is nothing to simulate them with.
Therefore, we fix the block at which we make the mainnet fork and keep it as the reference point of the loop: one checkpoint from which tests are reproducible, and several fresh ones — to check behavior on current network state. Let's break down how this is covered.
What an isolated stand provides: speed, repeatability, control
An isolated stand is a local copy of network state at a captured block: contracts, balances, oracles and liquidity pools are available as they were at the moment of the snapshot.
Checks run on your machine or in a container, so scenario execution time drops from minutes of waiting for confirmations to seconds.
The key effect is reproducibility of checks.
A failure is recorded together with block number and transaction hash, so the defect is repeated by any engineer, rather than "sometimes on my machine." Dependency on external networks is removed: public node limits, transaction queue congestion and third-party state changes no longer cause false failures.
| Option | When it fits | Limitations |
|---|---|---|
| Public testnet | Acceptance of integrations with external services | State changes between runs, nodes rate-limit requests |
| Mainnet fork on your machine | Contract debugging, incident investigation | Requires an archive node, snapshot lives until restart |
| Fork in a shared team container | Regular checks, several engineers on one protocol | Require snapshot versioning and environment rotation |
| Private network from scratch | Deployment and migration verification | No real data or third-party liquidity, scenarios are poorer |
We store snapshots as a build artifact and bring up the stand on Foundry — a new engineer gets a working environment with one command. The class of failures changes: instead of infrastructure failures, only defects of the contract itself reach acceptance.
Options for organizing the test loop: comparison of approaches
One test loop does not cover all tasks: debugging contract logic and running a scenario against real liquidity pools require different network state.
Therefore, the verification loop is chosen for the class of task — from fast iterations on an isolated stand to reproducing a specific transaction that broke in the production network.
The criteria here are engineering: state reliability (whether the stand has the needed counterparties and balances), speed of the edit cycle and reproducibility — the result of a run must be repeatable by any engineer on the team, not only on the test author's machine.
| Option | When it fits | Limitations |
|---|---|---|
| Local stand | Contract logic verification, fast runs, debugging on known state | No real counterparties or liquidity: behavior depends on what was placed in the initial state |
| Public test network | Verification of integrations with external contracts, scenarios with multiple participants and wallets | State does not match production, its own limitations on node availability and confirmation time |
| Snapshot of public network state (mainnet fork) | Reproduction of a production scenario, verification against real protocols and balances | Valid at the moment of snapshot: after counterparties update, state becomes outdated, a new snapshot is needed |
In practice, the loop is assembled from two or three options: tools like Foundry bring up a local node on a state snapshot in seconds, and integrations are then run on a public test network.
Such a set covers both debugging and verification of external calls, and switching between environments does not require rewriting tests.
Stages of setting up a mainnet fork: from audit to handover
A mainnet fork is a local copy of the network with real balances, contract storage and call history, not a simplified stub. The order of work here is more important than speed: if the snapshot point is fixed before scenarios are agreed, the stand will have to be reassembled.
- Scenario audit. We compile a list of contracts, participants and external addresses that must respond during the test. Without it, the stand fails on the first call.
- Choosing the snapshot point. We fix state at a specific block — after the necessary migrations, but before events that distort test data. Balances and account roles depend on it.
- Setting up the stand. We bring up a node, deploy contracts and a local explorer, enable accelerated block production so checks do not wait for confirmations.
- Account labeling. We distribute roles — administrator, issuer, holder, test user — with their own keys and balances. This makes access restrictions visible, not just successful paths.
- Substituting external responses. Rates, quotes and bridge responses are replaced with predictable values: the scenario stops depending on the external network.
- Running scenarios. We check standard operations, failures, repeated calls and limits. Discrepancies with the main network are analyzed separately: some of them are a feature of the fork.
- Handing over the loop. The customer's team reproduces the stand from scratch according to the instructions; we hand over the description of roles, configuration and applicability boundaries.
As a result, the loop is brought up again and gives the same result: checks run on data close to production.
Handover kit: stand configurations, diagrams, verification regulations
We assemble the handover kit so that the stand can be brought up again without us: the mainnet fork is described declaratively, not "manually in the console." Everything configured during loop deployment is recorded in a repository with change history — using it, the customer's engineer reproduces the environment and sees which edit broke what.
- Stand configuration — node and mainnet fork files: genesis block parameters, enabled modules, gas limits, endpoint addresses and environment variables with an explanation of each.
- Deployment diagram — a diagram of the loop with host roles, network separation and connection points to external services, plus startup order and dependencies between components.
- Access policies — which engineers and service accounts perform which operations, which keys are stored in the secret store, where the node is exposed externally and where it is accessible only within the loop.
- Test account sets — wallets with known keys for the local network, the procedure for issuing native tokens and a description of the scenario covered by each group.
- Snapshot update regulation — frequency of rebuilding state from the main network, criteria for snapshot staleness and the procedure for rolling back to the previous one without losing test work.
- Loop documentation — service map, analysis of typical failures and actions for them, instructions for a new engineer and a stand acceptance checklist.
Case: moving checks to an isolated loop and metrics before and after
The project is a protocol of several connected contracts with external dependencies: oracles, liquidity pools, tokens with real distribution.
Checks ran on a local stand that was brought up from scratch, and a noticeable share of scenarios simply did not run there — the needed positions and addresses do not exist in a clean environment. Those that did run passed successfully, but failed in the public network.
What was changed
Checks were moved to an isolated loop: a private node with a mainnet fork at a fixed block, a state snapshot as the base for each run, rollback after completion — state does not accumulate between runs.
External conditions — rates, pool depth, balances — were fixed with stubs at the same block, so a run is reproduced one-to-one. The loop is brought up via Anvil and lives exactly as long as the check runs — the shared stand no longer idles for one engineer's tasks.
Metrics before and after
- share of scenarios failing due to environment rather than code: 41% → 5%
- full run of the check suite: 34 min → 9 min
- failures at release: every fourth release → every twentieth
Release stability no longer depends on how accurately the stand reproduces the main network: the loop reproduces it by definition — at a fixed block.
A new engineer brings it up locally with one command and gets the same results as everyone else — discrepancies between machines are gone from failure analysis.
What to do if the test stand is already deployed and working?
There is no need to tear anything down or reconfigure from scratch. A snapshot of real state is connected as an additional data source next to the existing stand: a separate node with a copy of the network fixed at a specific height, the same contract addresses and the same keys. Existing scenarios continue to execute — only the place from which the stand reads state changes.
Migration of checks proceeds in portions. First, we move to the mainnet fork those paths that depend on external conditions: real balances, behavior of third-party contracts, oracle responses. The rest — as the team is ready.
The sign of a correct migration is simple: the same scenario on the previous stand and on the snapshot gives the same result, and a discrepancy immediately shows where the check relied on artificial data.
Scenario compatibility is separate work. Some checks rely on predefined states: set balances, indefinite permissions, substitute responses from external services.
On real state they behave differently, so we consciously leave such checks in the previous loop, while on the snapshot we run what models real conditions.
We also preserve the regulations: the order of running checks, the composition of reports, the rules for recording results remain the same — the source of state changes, not the process.
The gradual transition removes the main risk: until the new scheme has proven stable, the stand operates in mixed mode, and fork unavailability does not block the build.
We keep this mode for as long as needed for the team to see a more reliable picture and decide for itself what to migrate completely.
Technical doubts: answers to frequent questions about the stand
A stand on a mainnet fork reproduces both contract code and account state at the selected block height. Therefore, scenarios run against already deployed contracts — the same ones that operate in the network — without risk to production assets and without redeployment.
Reproduction accuracy is limited by the moment of the snapshot: the longer the stand lives without updating, the more balances and accumulated state diverge.
We update the snapshot according to the regulation and before major runs, keep external services within the stand loop, and support includes snapshot integrity checks and availability monitoring.
How closely does behavior on the stand match the production network?
Bytecode and virtual machine rules are the same — contract behavior is not substituted. Discrepancies are possible only where the result depends on external data: oracle rates, transaction order, block timings. We cover these points with deterministic stubs and a fixed block production rate.
How is the snapshot updated and can it be rolled back?
A new snapshot is brought up next to the old one: first we compare run results, then switch the stand. We keep the previous one — if the update changed behavior, we return to it.
What happens to already deployed contracts?
The snapshot brings them together with state: addresses and storage are the same as in the network. We deploy the improved version as a copy — we check migration and compatibility without losing the source data for comparison.
Can we do without external services in the loop?
Yes: oracles, indexers and node providers are replaced with emulators with controlled responses. The run becomes reproducible and does not depend on the limits and availability of third-party infrastructure.
Let's discuss the task: how the work plan for the stand is built
To come back with a timeline estimate, rather than a range "from and to," we need the engineering context of the task.
A mainnet fork is work with real network state at a specific block height, and the scope depends on how many external protocols your contracts call during execution.
To discuss the task, it is enough to provide:
- the contract repository, release branch and networks where they are already deployed;
- the block height for the state slice or the requirement "latest finalized";
- external dependencies: oracles, liquidity pools, third-party protocols that are called;
- verification scenarios — which paths must work before release, including rollbacks and failure handling;
- access: test keys with balances, deployment configs, private image registry;
- release timeline constraints and who on your side accepts the result.
Then two or three days go into analyzing the repository and running scenarios on a draft stand — this way the estimate relies on facts, not assumptions.
We return with one document: a work plan by stages, a timeline estimate with buffer for unstable dependencies, team composition with roles and points where your decision is needed.
After approval, work starts: we bring up the stand on your infrastructure or ours, and issue access by roles. We show the first reproducible run earlier than we close the entire scope — you see that the fork behaves like mainnet before the main effort is invested.







