Smart Contract Integration Testing with Forked Networks and Tenderly

Smart Contract Integration Testing with Forked Networks and Tenderly

Unit tests tell you your contract logic is correct in isolation. Integration tests tell you it works with the real DeFi ecosystem — live Uniswap pools, actual AAVE markets, production Chainlink price feeds. You need both.

Forked network testing lets you run your contracts against a snapshot of mainnet state without spending real ETH. You get the real contracts, real liquidity, real price data — but in a sandboxed environment where you control time, balances, and block numbers. Combined with Tenderly's simulation tools, you get a complete integration testing workflow that catches protocol-level bugs before deployment.

Understanding Network Forking

When you fork a network, your local node starts from a copy of the remote chain's state at a specific block. Any state you haven't touched comes from the real chain. Any state you do modify is local only.

This means:

  • The real Uniswap router contract is available at its mainnet address
  • Real liquidity exists in real pools
  • Real token contracts behave exactly as they do on mainnet
  • You can test interactions without deploying to testnet

The fork re-fetches state from the remote RPC as needed (using an archive node for historical data). This is slower than a purely local simulation but far more realistic.

Forking with Hardhat Network

Configure forking in hardhat.config.js:

require("@nomicfoundation/hardhat-toolbox");

module.exports = {
  networks: {
    hardhat: {
      forking: {
        url: process.env.MAINNET_RPC_URL, // Alchemy, Infura, or Ankr
        blockNumber: 19500000, // Pin to specific block for reproducibility
      },
    },
  },
  solidity: "0.8.24",
};

Pin the block number. Without pinning, tests pass today and fail tomorrow when mainnet state changes (liquidity shifts, prices move). Pinning makes tests deterministic.

Run tests against the fork:

MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY npx hardhat test

Or fork dynamically in tests:

const { network } = require("hardhat");

before(async function () {
  await network.provider.request({
    method: "hardhat_reset",
    params: [{
      forking: {
        jsonRpcUrl: process.env.MAINNET_RPC_URL,
        blockNumber: 19500000,
      },
    }],
  });
});

Forking with Anvil (Foundry)

Foundry's Anvil is significantly faster than Hardhat's fork mode:

# Start a forked local node
anvil --fork-url $MAINNET_RPC_URL --fork-block-number 19500000 --port 8545

# In another terminal, run tests against it
forge test --rpc-url http://localhost:8545

Or configure in foundry.toml to fork automatically during tests:

[profile.default]
fork_url = "${MAINNET_RPC_URL}"
fork_block_number = 19500000

The Anvil fork caches state locally at ~/.foundry/cache/rpc/, so subsequent test runs don't re-fetch data from the RPC. Large test suites run much faster after the first run.

Testing Against Real DeFi Contracts

Setup: Getting Real Token Addresses

// Use mainnet addresses in tests
const ADDRESSES = {
  WETH: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
  USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  DAI: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
  UNISWAP_V3_ROUTER: "0xE592427A0AEce92De3Edee1F18E0157C05861564",
  UNISWAP_V3_FACTORY: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
  AAVE_V3_POOL: "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2",
};

Testing a Uniswap V3 Integration

const { ethers } = require("hardhat");
const { expect } = require("chai");

const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const UNISWAP_V3_ROUTER = "0xE592427A0AEce92De3Edee1F18E0157C05861564";

describe("MyProtocol Uniswap Integration", function () {
  let myProtocol, weth, usdc, router;
  let whale; // An address with large WETH balance

  before(async function () {
    // Connect to real contracts on the fork
    weth = await ethers.getContractAt("IERC20", WETH);
    usdc = await ethers.getContractAt("IERC20", USDC);
    router = await ethers.getContractAt("ISwapRouter", UNISWAP_V3_ROUTER);

    // Deploy our contract
    const MyProtocol = await ethers.getContractFactory("MyProtocol");
    myProtocol = await MyProtocol.deploy(UNISWAP_V3_ROUTER);
  });

  it("Should swap WETH for USDC via Uniswap V3", async function () {
    const [deployer] = await ethers.getSigners();
    const swapAmount = ethers.parseEther("1"); // 1 WETH

    // Give deployer some WETH (impersonate a whale)
    await giveTokens(deployer.address, WETH, swapAmount);

    await weth.approve(myProtocol.target, swapAmount);

    const usdcBefore = await usdc.balanceOf(deployer.address);
    await myProtocol.swapWethToUsdc(swapAmount, 0);
    const usdcAfter = await usdc.balanceOf(deployer.address);

    // With 1 WETH, we should get roughly $3000+ USDC (at block 19500000)
    const usdcReceived = usdcAfter - usdcBefore;
    expect(usdcReceived).to.be.gt(ethers.parseUnits("2000", 6)); // > $2000
    expect(usdcReceived).to.be.lt(ethers.parseUnits("5000", 6)); // < $5000 (sanity check)
  });
});

Testing Against AAVE V3

const AAVE_POOL = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2";
const AAVE_WETH_ATOKEN = "0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E5";

describe("AAVE Integration", function () {
  it("Should supply and borrow against AAVE", async function () {
    const [user] = await ethers.getSigners();
    const aavePool = await ethers.getContractAt("IPool", AAVE_POOL);
    const aWeth = await ethers.getContractAt("IERC20", AAVE_WETH_ATOKEN);

    const supplyAmount = ethers.parseEther("10");
    await giveTokens(user.address, WETH, supplyAmount);
    await weth.connect(user).approve(AAVE_POOL, supplyAmount);

    // Supply to AAVE
    await aavePool.connect(user).supply(WETH, supplyAmount, user.address, 0);

    // Verify we received aTokens
    const aWethBalance = await aWeth.balanceOf(user.address);
    expect(aWethBalance).to.be.approximately(supplyAmount, ethers.parseEther("0.001"));

    // Borrow USDC against collateral
    const borrowAmount = ethers.parseUnits("10000", 6); // $10,000 USDC
    await aavePool.connect(user).borrow(USDC, borrowAmount, 2, 0, user.address);

    expect(await usdc.balanceOf(user.address)).to.be.gte(borrowAmount);
  });
});

Impersonating Accounts

To test with tokens you don't own, impersonate a whale address:

async function giveTokens(recipient, tokenAddress, amount) {
  // Find a whale (address with large token balance) — check Etherscan
  const whales = {
    [WETH]: "0xF04a5cC80B1E94C69B48f5ee68a08CD2F09A7c3E",
    [USDC]: "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503",
    [DAI]: "0x60FaAe176336dAb62e284Fe19B885B095d29fB7f",
  };

  const whale = whales[tokenAddress];
  if (!whale) throw new Error(`No whale configured for ${tokenAddress}`);

  // Impersonate the whale
  await network.provider.request({
    method: "hardhat_impersonateAccount",
    params: [whale],
  });

  // Give the whale ETH for gas
  await network.provider.send("hardhat_setBalance", [
    whale,
    "0x56BC75E2D63100000", // 100 ETH
  ]);

  const signer = await ethers.getSigner(whale);
  const token = await ethers.getContractAt("IERC20", tokenAddress);
  await token.connect(signer).transfer(recipient, amount);

  await network.provider.request({
    method: "hardhat_stopImpersonatingAccount",
    params: [whale],
  });
}

In Foundry, the equivalent is:

function giveTokens(address recipient, address token, uint256 amount) internal {
    address whale = 0xF04a5cC80B1E94C69B48f5ee68a08CD2F09A7c3E;
    vm.prank(whale);
    IERC20(token).transfer(recipient, amount);
}

Simulating Governance Proposals

Testing protocol upgrades via governance requires simulating the entire proposal lifecycle:

const COMPOUND_GOVERNOR = "0xc0Da02939E1441F497fd74F78cE7Decb17B66529";
const COMPOUND_TIMELOCK = "0x6d903f6003cca6255D85CcA4D3B5E5146dC33925";

describe("Governance Simulation", function () {
  it("Should execute a governance proposal", async function () {
    const governor = await ethers.getContractAt("GovernorBravo", COMPOUND_GOVERNOR);
    const timelockAdmin = COMPOUND_TIMELOCK;

    // Impersonate the timelock to execute changes directly (bypass voting for tests)
    await network.provider.request({
      method: "hardhat_impersonateAccount",
      params: [timelockAdmin],
    });
    await network.provider.send("hardhat_setBalance", [
      timelockAdmin,
      "0x56BC75E2D63100000",
    ]);

    const timelockSigner = await ethers.getSigner(timelockAdmin);

    // Execute the change as if it passed governance
    const target = myProtocol.target;
    const calldata = myProtocol.interface.encodeFunctionData("updateFee", [50]); // 0.5%

    await timelockSigner.sendTransaction({ to: target, data: calldata });

    expect(await myProtocol.feeBasisPoints()).to.equal(50);
  });
});

Using Tenderly for Transaction Simulation

Tenderly provides simulation APIs that let you test transactions without deploying to any network — not even a fork. This is useful for:

  • Testing before you have a deployment
  • Simulating transactions from arbitrary addresses
  • Debugging transaction failures with full traces

Setting Up Tenderly

const axios = require("axios");

const TENDERLY_API = "https://api.tenderly.co/api/v1";
const TENDERLY_KEY = process.env.TENDERLY_ACCESS_KEY;
const TENDERLY_PROJECT = process.env.TENDERLY_PROJECT_SLUG;
const TENDERLY_ACCOUNT = process.env.TENDERLY_ACCOUNT_ID;

async function simulateTransaction(from, to, data, value = "0x0") {
  const response = await axios.post(
    `${TENDERLY_API}/account/${TENDERLY_ACCOUNT}/project/${TENDERLY_PROJECT}/simulate`,
    {
      network_id: "1",
      from,
      to,
      input: data,
      value,
      save: true,
      save_if_fails: true,
    },
    {
      headers: { "X-Access-Key": TENDERLY_KEY },
    }
  );

  return response.data.transaction;
}

Using Tenderly Forks via RPC

Tenderly Forks create a persistent forked environment accessible via RPC:

async function createTenderlyFork(networkId = "1", blockNumber) {
  const response = await axios.post(
    `${TENDERLY_API}/account/${TENDERLY_ACCOUNT}/project/${TENDERLY_PROJECT}/fork`,
    {
      network_id: networkId,
      block_number: blockNumber,
    },
    {
      headers: { "X-Access-Key": TENDERLY_KEY },
    }
  );

  const forkId = response.data.simulation_fork.id;
  const rpcUrl = `https://rpc.tenderly.co/fork/${forkId}`;

  return { forkId, rpcUrl };
}

Then use the fork RPC URL in your Hardhat config or pass it to ethers.js directly.

Tenderly DevNets for CI

Tenderly DevNets are purpose-built for CI — ephemeral forked environments that spin up in seconds:

# .github/workflows/integration-tests.yml
name: Integration Tests

on: [push, pull_request]

jobs:
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Create Tenderly DevNet
        id: devnet
        run: |
          DEVNET=$(curl -s -X POST \
            "https://api.tenderly.co/api/v1/account/${{ secrets.TENDERLY_ACCOUNT }}/project/${{ secrets.TENDERLY_PROJECT }}/devnet/container/start" \
            -H "X-Access-Key: ${{ secrets.TENDERLY_KEY }}" \
            -H "Content-Type: application/json" \
            -d '{"display_name":"ci-test","network_id":"1"}')
          echo "rpc_url=$(echo $DEVNET | jq -r '.container.connectivityConfig.endpoints[0].uri')" >> $GITHUB_OUTPUT

      - name: Run integration tests
        env:
          FORK_URL: ${{ steps.devnet.outputs.rpc_url }}
        run: npx hardhat test --network hardhat

      - name: Stop DevNet
        if: always()
        run: |
          # Cleanup (DevNets auto-expire but explicit cleanup is good practice)
          echo "DevNet cleanup"

CI Workflow for Fork Tests

name: Fork Integration Tests

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 6 * * *"  # Daily at 6am to catch protocol changes

jobs:
  fork-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Foundry
        uses: foundry-rs/foundry-toolchain@v1

      - name: Run fork tests
        env:
          MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }}
        run: |
          forge test \
            --fork-url $MAINNET_RPC_URL \
            --fork-block-number 19500000 \
            --match-path "test/integration/*" \
            -vvv

Performance Tips for Fork Tests

Fork tests are slower than pure unit tests because they fetch state from RPC. Strategies to keep them fast:

Cache aggressively: Anvil's RPC cache and Hardhat's forking cache both save fetched state. Don't clear these between CI runs — cache them in your CI artifact store.

Minimize RPC calls: Each state access (balance checks, storage reads) on an unmodified address hits the RPC. Batch reads where possible.

Separate test suites: Run unit tests on every commit, fork tests only on main or nightly. Fast feedback for common changes, thorough testing before deployment.

Use block pinning consistently: Pick a block and stick with it for a test cycle. Changing the block invalidates caches and re-fetches everything.

Fork tests represent your contract's actual behavior in the wild. Run them before any mainnet deployment, after any protocol you depend on upgrades, and daily if your protocol has ongoing user funds. The cost of a forking test run is measured in minutes. The cost of a mainnet bug is measured in ETH.

Read more

Start now free