Forking Mainnet for Safe DeFi Tests

Forking Mainnet for Safe DeFi Tests

Forking mainnet locally has saved me from reckless deploys more times than audits. Every liquidation bot, rebalance hook, or hedging policy now touches a fork before it touches real liquidity. Here’s how I keep those forks close to reality without over-engineering the stack.

Why I Fork Mainnet Before Touching Funds

Strategy tests against mocks always pass because mocks never change. Forking lets me hit the real USDC allowance, the exact Uniswap pool tick, and the toxic whale account I’m trying to compete with. It matters when:

  • A strategy depends on a specific oracle or DeFi pool being at a known slot
  • I need to impersonate counterparties to simulate toxic flow or failing keepers
  • I want reproducible breakpoints for debugging reverts and gas blow-ups

With a fork, I can replay a MEV sandwich from block 21347650, run my strategy with tweaked parameters, and see whether it still clears after gas bumps. No amount of unit testing matches this.

How the Forking Stack Fits Together

Hardhat for fast experiments

Hardhat’s built-in fork mode copies state from any JSON-RPC endpoint and exposes helpers like hardhat_impersonateAccount out of the box.1 I keep the config short:

// hardhat.config.ts
import { HardhatUserConfig } from 'hardhat/config';
import '@nomicfoundation/hardhat-ethers';

const config: HardhatUserConfig = {
  solidity: '0.8.24',
  networks: {
    hardhat: {
      forking: {
        url: process.env.ALCHEMY_MAINNET!,
        blockNumber: 21347650,
      },
      accounts: { count: 50 },
      mining: { auto: false, interval: 1_000 }, // deterministic block times
    },
  },
};

export default config;

blockNumber pins the fork so CI runs on the same state every time. I only bump it when I need fresher liquidity snapshots. The manual mining interval lets me step through state transitions by calling hardhat_mine.

Anvil for longer-running soak tests

When I need to watch 200 blocks of funding rates or test keeper drift, I swap to Foundry’s anvil because it stays stable for hours and exposes extra toggles (--steps-tracing, --init-balance, --code-size).2

anvil \
  --fork-url $QUICKNODE_MAINNET \
  --fork-block-number 21347000 \
  --chain-id 1 \
  --steps-tracing \
  --code-size 0x6000 \
  --mnemonic "test test test test test test test test test test test junk"

That command spins up a fork that mirrors Ethereum but lets me spam traces and inspect every opcode. Foundry tests can then point to the same RPC:

FOUNDRY_PROFILE=forked \
forge test \
  --fork-url http://127.0.0.1:8545 \
  --fork-block-number 21347000 \
  --gas-report

--gas-report shows whether refactors improved my keeper run, and keeping it on the same block number ensures diffable output.

Tenderly for collaboration

If a teammate needs to reproduce a failure without syncing anything, I snapshot the fork with Tenderly and share the URL. Tenderly keeps the state serverside and lets the team submit transactions through a UI, which is handy when product managers want to watch a liquidation path without touching terminals.3

Code Examples: Impersonation, Funding, and Assertions

I keep a single script that unlocks counterparties, funds bots, and performs the action under test. Hardhat makes this boring:

// scripts/seed-strategy.ts
import { ethers, network } from 'hardhat';

const USDC_WHALE = '0x55fe002aeff02f77364de339a1292923a15844b8';
const STRATEGY = '0x1234...cafe';

async function main() {
  await network.provider.request({
    method: 'hardhat_impersonateAccount',
    params: [USDC_WHALE],
  });

  const whale = await ethers.getSigner(USDC_WHALE);
  const strategy = await ethers.getContractAt('GridStrategy', STRATEGY);

  const tx = await strategy.connect(whale).rebalance({ gasPrice: 0 });
  console.log(`Rebalance executed in ${tx.gasLimit?.toString()} gas`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

I run it against the fork with:

ALCHEMY_MAINNET=https://eth-mainnet.g.alchemy.com/v2/KEY \
npx hardhat run scripts/seed-strategy.ts --network hardhat

For Solidity-first tests I lean on Foundry’s cheatcodes to assert balances before and after the keeper run:

function test_rebalance_handles_negative_funding() public {
    vm.createSelectFork(MAINNET_RPC, 21347000);
    vm.prank(USDC_WHALE);
    strategy.rebalance();
    assertApproxEqRel(strategy.pnl(), -0.0003 ether, 0.0001 ether); // tolerates 10 bps drift
}

Pinning the block makes that assertion stable enough for CI, and the fork lets me probe exactly how the funding payment was computed.

Risk Analysis

  • Technical: Forks drift the moment the real chain moves. If you forget to pin blockNumber, deterministic tests disappear and repro steps become unreliable. Fork nodes also inherit RPC provider quirks—if your upstream throttles traces, your forked traces may randomly fail.
  • Economic: Simulated gas on a fork is optimistic because failed competing transactions never happen. Always pad gas budgets when deploying; I add 15% to keeper costs observed on forks to account for mainnet contention.
  • Operational: Forks hide infra latency. CI that runs against anvil inside the same machine feels instant, but production bots hit remote RPCs through TLS. Measure both, or you will under-provision alert thresholds. Also remember to scrub API keys from shared fork configs; leaking them in repos is an instant rate-limit death.

Practical Takeaways

  • Pin fork block numbers so the whole team—and CI—debugs the same state.
  • Keep one-liners for Hardhat (fast repro) and Anvil (long-running tests); switch depending on scenario.
  • Automate impersonation + seeding scripts so reproducing a failing swap is a single command.
  • Budget extra gas and latency when translating fork results to production expectations.
  • Share hosted forks (Tenderly or similar) when non-engineers need to inspect the same scenario without installing toolchains.