Testing DeFi Protocol Interactions: Forks, Mocks, and Attack Simulations

Testing DeFi Protocol Interactions: Forks, Mocks, and Attack Simulations

Testing a DeFi protocol is unlike testing any other software. Your contract interacts with Uniswap liquidity pools, Chainlink price oracles, Aave lending markets, and Compound interest rate models—all live, all changing, all outside your control. A naive implementation collapses when prices move, fails when pools are illiquid, or gets drained by flash loan attacks.

This guide covers the testing strategies that production DeFi teams actually use: mainnet forking to test against real protocol state, mock oracles for price manipulation testing, flash loan attack simulations, and invariant testing for AMM math.

The DeFi Testing Challenge

Standard unit testing falls short for DeFi because:

  1. Protocol state matters. Uniswap pool reserves change every block. Your test needs realistic reserves, not mocked ones.
  2. Multiple contracts interact. A yield aggregator calls Uniswap, then Aave, then your vault. Any step can fail.
  3. Attacks are composable. Flash loans let attackers borrow, manipulate, profit, and repay in one transaction.
  4. Math has subtle edge cases. AMM curves, interest rate calculations, and liquidation math can overflow or lose precision at extreme values.

The answer to (1) and (2) is mainnet forking. The answer to (3) is attack simulation. The answer to (4) is invariant and fuzz testing.

Mainnet Forking with Hardhat

Mainnet forking creates a local copy of Ethereum mainnet state at a specific block. Your tests interact with real Uniswap contracts, real USDC balances, real Aave liquidity—without spending real ETH or waiting for transactions.

// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");

module.exports = {
  networks: {
    hardhat: {
      forking: {
        url: `https://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`,
        blockNumber: 19500000, // pin to a specific block for reproducible tests
      },
    },
  },
  solidity: "0.8.20",
};

Pin to a specific block number. If you use the latest block, your tests become non-deterministic as mainnet progresses.

Contracts for Testing

We'll test a yield router that swaps tokens via Uniswap V3 and deposits the proceeds into Aave:

// contracts/YieldRouter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

interface ISwapRouter {
    struct ExactInputSingleParams {
        address tokenIn;
        address tokenOut;
        uint24 fee;
        address recipient;
        uint256 amountIn;
        uint256 amountOutMinimum;
        uint160 sqrtPriceLimitX96;
    }
    function exactInputSingle(ExactInputSingleParams calldata params) external returns (uint256 amountOut);
}

interface IAavePool {
    function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external;
    function withdraw(address asset, uint256 amount, address to) external returns (uint256);
}

contract YieldRouter is Ownable {
    using SafeERC20 for IERC20;

    ISwapRouter public immutable swapRouter;
    IAavePool public immutable aavePool;
    address public immutable WETH;
    address public immutable USDC;

    uint256 public totalDeposited;
    mapping(address => uint256) public userDeposits;

    event Routed(address indexed user, uint256 ethIn, uint256 usdcDeposited);
    event Withdrawn(address indexed user, uint256 usdcAmount);

    error SlippageExceeded(uint256 received, uint256 minimum);
    error NothingToWithdraw();

    constructor(
        address _swapRouter,
        address _aavePool,
        address _weth,
        address _usdc,
        address _owner
    ) Ownable(_owner) {
        swapRouter = ISwapRouter(_swapRouter);
        aavePool = IAavePool(_aavePool);
        WETH = _weth;
        USDC = _usdc;
    }

    /// @notice Swaps WETH for USDC and deposits into Aave
    function routeToAave(uint256 wethAmount, uint256 minUsdc) external {
        IERC20(WETH).safeTransferFrom(msg.sender, address(this), wethAmount);
        IERC20(WETH).approve(address(swapRouter), wethAmount);

        uint256 usdcReceived = swapRouter.exactInputSingle(
            ISwapRouter.ExactInputSingleParams({
                tokenIn: WETH,
                tokenOut: USDC,
                fee: 500, // 0.05% pool
                recipient: address(this),
                amountIn: wethAmount,
                amountOutMinimum: minUsdc,
                sqrtPriceLimitX96: 0
            })
        );

        if (usdcReceived < minUsdc) revert SlippageExceeded(usdcReceived, minUsdc);

        IERC20(USDC).approve(address(aavePool), usdcReceived);
        aavePool.supply(USDC, usdcReceived, address(this), 0);

        userDeposits[msg.sender] += usdcReceived;
        totalDeposited += usdcReceived;

        emit Routed(msg.sender, wethAmount, usdcReceived);
    }
}

Integration Tests with Mainnet Fork

// test/YieldRouter.fork.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

// Mainnet contract addresses (pinned)
const UNISWAP_V3_ROUTER = "0xE592427A0AEce92De3Edee1F18E0157C05861564";
const AAVE_V3_POOL = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2";
const WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";

// Whale addresses with large balances (from mainnet state)
const WETH_WHALE = "0x2F0b23f53734252Bda2277357e97e1517d6B042A";

describe("YieldRouter (Mainnet Fork)", function () {
  let router, weth, usdc;
  let owner, alice;

  before(async function () {
    [owner, alice] = await ethers.getSigners();

    weth = await ethers.getContractAt("IERC20", WETH_ADDRESS);
    usdc = await ethers.getContractAt("IERC20", USDC_ADDRESS);

    const YieldRouter = await ethers.getContractFactory("YieldRouter");
    router = await YieldRouter.deploy(
      UNISWAP_V3_ROUTER,
      AAVE_V3_POOL,
      WETH_ADDRESS,
      USDC_ADDRESS,
      owner.address
    );

    // Impersonate a whale to get WETH for testing
    await ethers.provider.send("hardhat_impersonateAccount", [WETH_WHALE]);
    const whale = await ethers.getSigner(WETH_WHALE);

    // Fund test accounts
    await weth.connect(whale).transfer(alice.address, ethers.parseEther("10"));

    await ethers.provider.send("hardhat_stopImpersonatingAccount", [WETH_WHALE]);
  });

  describe("routeToAave", function () {
    it("swaps WETH for USDC and deposits to Aave", async function () {
      const wethIn = ethers.parseEther("1");
      // Get approximate USDC output (ETH ~$3000 at pinned block, set 1% slippage)
      const minUsdc = 2970n * 10n ** 6n; // $2970 in USDC (6 decimals)

      await weth.connect(alice).approve(await router.getAddress(), wethIn);

      const tx = await router.connect(alice).routeToAave(wethIn, minUsdc);
      const receipt = await tx.wait();

      // Verify event
      const routedEvent = receipt.logs
        .map(log => { try { return router.interface.parseLog(log); } catch { return null; } })
        .find(e => e?.name === "Routed");

      expect(routedEvent).to.not.be.undefined;
      expect(routedEvent.args.user).to.equal(alice.address);
      expect(routedEvent.args.usdcDeposited).to.be.greaterThan(minUsdc);

      // Alice's deposit tracked
      expect(await router.userDeposits(alice.address)).to.equal(routedEvent.args.usdcDeposited);
    });

    it("reverts when slippage tolerance is too tight", async function () {
      const wethIn = ethers.parseEther("1");
      const impossibleMinUsdc = 10000n * 10n ** 6n; // $10,000 — impossible for 1 ETH

      await weth.connect(alice).approve(await router.getAddress(), wethIn);

      // Uniswap will revert with "Too little received" before our check
      await expect(
        router.connect(alice).routeToAave(wethIn, impossibleMinUsdc)
      ).to.be.reverted;
    });
  });
});

Mock Oracle Testing

Price oracle manipulation is one of the most common DeFi attack vectors. Test your oracle-dependent code against manipulated prices:

// contracts/mocks/MockChainlinkOracle.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface AggregatorV3Interface {
    function latestRoundData() external view returns (
        uint80 roundId,
        int256 answer,
        uint256 startedAt,
        uint256 updatedAt,
        uint80 answeredInRound
    );
    function decimals() external view returns (uint8);
}

contract MockChainlinkOracle is AggregatorV3Interface {
    int256 public price;
    uint8 public _decimals;
    uint256 public updatedAt;
    bool public shouldRevert;

    constructor(int256 _initialPrice, uint8 _dec) {
        price = _initialPrice;
        _decimals = _dec;
        updatedAt = block.timestamp;
    }

    function setPrice(int256 _price) external {
        price = _price;
        updatedAt = block.timestamp;
    }

    function setStale(uint256 _updatedAt) external {
        updatedAt = _updatedAt;
    }

    function setRevert(bool _shouldRevert) external {
        shouldRevert = _shouldRevert;
    }

    function latestRoundData() external view override returns (
        uint80 roundId, int256 answer, uint256 startedAt,
        uint256 _updatedAt, uint80 answeredInRound
    ) {
        require(!shouldRevert, "Oracle reverted");
        return (1, price, block.timestamp, updatedAt, 1);
    }

    function decimals() external view override returns (uint8) {
        return _decimals;
    }
}
// test/oracle-dependent.test.js
describe("Oracle-dependent contract", function () {
  let oracle, collateralVault;

  beforeEach(async function () {
    const MockOracle = await ethers.getContractFactory("MockChainlinkOracle");
    // $3000 ETH price with 8 decimals (Chainlink standard)
    oracle = await MockOracle.deploy(3000_00000000n, 8);

    const CollateralVault = await ethers.getContractFactory("CollateralVault");
    collateralVault = await CollateralVault.deploy(await oracle.getAddress());
  });

  it("uses current oracle price for collateral valuation", async function () {
    const [, alice] = await ethers.getSigners();
    await collateralVault.connect(alice).deposit({ value: ethers.parseEther("1") });

    // At $3000/ETH, 1 ETH = $3000 collateral
    const value = await collateralVault.collateralValue(alice.address);
    expect(value).to.be.closeTo(3000n * 10n ** 18n, 10n ** 15n);
  });

  it("reverts on stale oracle price", async function () {
    // Set price to 2 hours ago (typically > max staleness)
    await oracle.setStale(Math.floor(Date.now() / 1000) - 7200);

    const [, alice] = await ethers.getSigners();
    await collateralVault.connect(alice).deposit({ value: ethers.parseEther("1") });

    await expect(
      collateralVault.collateralValue(alice.address)
    ).to.be.revertedWith("Stale oracle");
  });

  it("handles oracle revert gracefully", async function () {
    await oracle.setRevert(true);
    const [, alice] = await ethers.getSigners();

    await expect(
      collateralVault.connect(alice).deposit({ value: ethers.parseEther("1") })
    ).to.be.revertedWith("Oracle reverted");
  });

  it("liquidates correctly when price drops", async function () {
    const [owner, alice, liquidator] = await ethers.getSigners();

    // Alice deposits at $3000 ETH
    await collateralVault.connect(alice).depositAndBorrow(
      { value: ethers.parseEther("1") },
      2000n * 10n ** 18n // borrow $2000 (66% LTV)
    );

    // Price crashes to $1000 — Alice is now undercollateralized
    await oracle.setPrice(1000_00000000n);

    // Liquidator should be able to liquidate
    await expect(
      collateralVault.connect(liquidator).liquidate(alice.address)
    ).to.emit(collateralVault, "Liquidated").withArgs(alice.address);
  });
});

Flash Loan Attack Simulation

Test that your contract is resistant to flash loan attacks:

// contracts/attacks/FlashLoanAttacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// Simulates a flash loan attacker for testing purposes
interface IFlashLoan {
    function flashLoan(address receiver, address token, uint256 amount, bytes calldata data) external;
}

interface IVulnerablePool {
    function getPrice() external view returns (uint256);
    function buy(uint256 amount) external;
    function sell(uint256 amount) external;
}

contract FlashLoanAttacker {
    IFlashLoan public lender;
    IVulnerablePool public target;
    address public token;

    uint256 public attackProfit;

    constructor(address _lender, address _target, address _token) {
        lender = IFlashLoan(_lender);
        target = IVulnerablePool(_target);
        token = _token;
    }

    function attack(uint256 loanAmount) external {
        lender.flashLoan(address(this), token, loanAmount, "");
    }

    // Called by flash loan lender during the loan
    function onFlashLoan(address, address, uint256 amount, uint256 fee, bytes calldata) external returns (bytes32) {
        // 1. Use borrowed funds to manipulate pool price
        target.buy(amount / 2);

        // 2. Exploit the manipulated price
        uint256 manipulatedPrice = target.getPrice();
        // ... attack logic here

        // 3. Sell back to restore price
        target.sell(amount / 2);

        // 4. Repay loan + fee
        IERC20(token).approve(address(lender), amount + fee);

        attackProfit = IERC20(token).balanceOf(address(this)) - fee;
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}
// test/flash-loan-resistance.test.js
describe("Flash loan attack resistance", function () {
  it("oracle price is not manipulable by single-block token purchases", async function () {
    const { pool, token, attacker } = await loadFixture(deployAttackScenario);
    const profitBefore = await token.balanceOf(attacker.address);

    // Attempt flash loan attack
    await attacker.attack(ethers.parseEther("1000000"));

    const profitAfter = await token.balanceOf(attacker.address);
    // Attack should yield zero profit (or even cost gas)
    expect(profitAfter).to.be.lte(profitBefore);
  });

  it("reentrancy guard blocks nested calls", async function () {
    const { vault, reentrancyAttacker } = await loadFixture(deployWithVault);

    await expect(
      reentrancyAttacker.attack({ value: ethers.parseEther("1") })
    ).to.be.revertedWithCustomError(vault, "ReentrancyGuardReentrantCall");
  });
});

AMM Invariant Testing with Foundry

For AMM contracts (Uniswap-style or custom), invariant testing is essential:

// test/AMMInvariant.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/ConstantProductAMM.sol";

contract AMMHandler is Test {
    ConstantProductAMM amm;
    MockERC20 tokenA;
    MockERC20 tokenB;

    address[] actors = [address(0x1), address(0x2), address(0x3)];

    constructor(ConstantProductAMM _amm, MockERC20 _tokenA, MockERC20 _tokenB) {
        amm = _amm;
        tokenA = _tokenA;
        tokenB = _tokenB;
    }

    function swapAforB(uint256 actorSeed, uint256 amountIn) external {
        address actor = actors[actorSeed % actors.length];
        amountIn = bound(amountIn, 1, 1e24);

        tokenA.mint(actor, amountIn);
        vm.startPrank(actor);
        tokenA.approve(address(amm), amountIn);
        amm.swap(address(tokenA), amountIn, 0);
        vm.stopPrank();
    }

    function swapBforA(uint256 actorSeed, uint256 amountIn) external {
        address actor = actors[actorSeed % actors.length];
        amountIn = bound(amountIn, 1, 1e24);

        tokenB.mint(actor, amountIn);
        vm.startPrank(actor);
        tokenB.approve(address(amm), amountIn);
        amm.swap(address(tokenB), amountIn, 0);
        vm.stopPrank();
    }

    function addLiquidity(uint256 amountA, uint256 amountB) external {
        amountA = bound(amountA, 1e6, 1e24);
        amountB = bound(amountB, 1e6, 1e24);

        tokenA.mint(address(this), amountA);
        tokenB.mint(address(this), amountB);
        tokenA.approve(address(amm), amountA);
        tokenB.approve(address(amm), amountB);
        amm.addLiquidity(amountA, amountB);
    }
}

contract AMMInvariantTest is Test {
    ConstantProductAMM amm;
    MockERC20 tokenA;
    MockERC20 tokenB;
    AMMHandler handler;

    uint256 constant INITIAL_LIQUIDITY = 1_000_000e18;

    function setUp() public {
        tokenA = new MockERC20("Token A", "TKNA");
        tokenB = new MockERC20("Token B", "TKNB");
        amm = new ConstantProductAMM(address(tokenA), address(tokenB));

        // Seed initial liquidity
        tokenA.mint(address(this), INITIAL_LIQUIDITY);
        tokenB.mint(address(this), INITIAL_LIQUIDITY);
        tokenA.approve(address(amm), INITIAL_LIQUIDITY);
        tokenB.approve(address(amm), INITIAL_LIQUIDITY);
        amm.addLiquidity(INITIAL_LIQUIDITY, INITIAL_LIQUIDITY);

        handler = new AMMHandler(amm, tokenA, tokenB);
        targetContract(address(handler));
    }

    /// @notice k = reserveA * reserveB must never decrease (ignoring fees)
    function invariant_kNeverDecreases() public view {
        (uint256 resA, uint256 resB) = amm.getReserves();
        uint256 k = resA * resB;
        uint256 initialK = INITIAL_LIQUIDITY * INITIAL_LIQUIDITY;
        assertGe(k, initialK, "invariant: k must never decrease");
    }

    /// @notice Reserve balances must match actual token balances
    function invariant_reservesMatchBalances() public view {
        (uint256 resA, uint256 resB) = amm.getReserves();
        assertEq(tokenA.balanceOf(address(amm)), resA, "reserve A mismatch");
        assertEq(tokenB.balanceOf(address(amm)), resB, "reserve B mismatch");
    }

    /// @notice No single swap should give more than the input value
    function invariant_noValueCreation() public view {
        // In a constant-product AMM without fees, value is conserved
        // This checks a basic economic invariant
        (uint256 resA, uint256 resB) = amm.getReserves();
        assertGt(resA, 0, "reserves must remain positive");
        assertGt(resB, 0, "reserves must remain positive");
    }
}

Testing Compound/Aave Interest Accrual

Interest-bearing protocols need time-based tests:

describe("Interest accrual", function () {
  it("aToken balance increases over time on forked mainnet", async function () {
    const [, depositor] = await ethers.getSigners();

    // Impersonate USDC whale
    await ethers.provider.send("hardhat_impersonateAccount", [USDC_WHALE]);
    const whale = await ethers.getSigner(USDC_WHALE);
    await usdc.connect(whale).transfer(depositor.address, 10000n * 10n ** 6n);

    // Deposit into Aave
    const aavePool = await ethers.getContractAt(AAVE_POOL_ABI, AAVE_V3_POOL);
    await usdc.connect(depositor).approve(AAVE_V3_POOL, 10000n * 10n ** 6n);
    await aavePool.connect(depositor).supply(USDC_ADDRESS, 10000n * 10n ** 6n, depositor.address, 0);

    const aUSDC = await ethers.getContractAt("IERC20", A_USDC_ADDRESS);
    const balanceBefore = await aUSDC.balanceOf(depositor.address);

    // Fast forward 30 days
    await ethers.provider.send("evm_increaseTime", [30 * 24 * 60 * 60]);
    await ethers.provider.send("evm_mine", []);

    const balanceAfter = await aUSDC.balanceOf(depositor.address);
    expect(balanceAfter).to.be.greaterThan(balanceBefore);

    console.log(`Interest earned in 30 days: ${balanceAfter - balanceBefore} USDC wei`);
  });
});

CI Configuration for Fork Tests

Fork tests require an Alchemy/Infura API key. Store it as a secret and skip fork tests when the key isn't available:

// test/helpers/skip-if-no-fork.js
const shouldSkipForkTests = !process.env.ALCHEMY_API_KEY;

function skipIfNoFork(testFn) {
  if (shouldSkipForkTests) {
    return it.skip;
  }
  return testFn;
}

module.exports = { skipIfNoFork };
# .github/workflows/fork-tests.yml
name: Fork Tests
on:
  schedule:
    - cron: '0 6 * * *'  # daily at 6am
  push:
    branches: [main]
jobs:
  fork-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npx hardhat test test/**/*.fork.test.js
        env:
          ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }}

Run standard unit tests on every PR. Run fork tests nightly or on merges to main to avoid burning API credits.

Security Testing Checklist for DeFi

Before deploying any DeFi contract, your test suite should cover:

Oracle security

  • Stale price detection (staleness threshold enforced)
  • Oracle reverts handled gracefully
  • Price manipulation via single-block actions doesn't profit
  • TWAP oracle used for liquidation-sensitive calculations

Reentrancy

  • Reentrancy guard on all state-modifying external calls
  • Check-effects-interactions pattern verified
  • ERC-777 callback reentrancy (if applicable)

Flash loan resistance

  • Single-block price manipulation yields no profit
  • Collateral ratios hold under extreme price moves

Arithmetic

  • No overflow in multiplication before division
  • Rounding favors the protocol, not the user
  • Division by zero impossible (checked liquidity > 0)

Access control

  • Every privileged function has a test for unauthorized access
  • Time-locked admin functions work as specified
  • Pause/unpause tested

Economic invariants (via Foundry invariant tests)

  • Total debt <= total collateral (after fees)
  • AMM k-value invariant holds
  • No value creation from zero (dust attacks)

Summary

DeFi protocol testing requires a layered approach:

  1. Unit tests (Hardhat/Foundry) — verify contract logic in isolation
  2. Fork tests — verify integration with live protocols at pinned mainnet state
  3. Mock oracle tests — verify behavior under price manipulation
  4. Attack simulations — verify flash loan and reentrancy resistance
  5. Invariant tests (Foundry) — verify economic properties hold under random sequences

Each layer finds different bugs. The teams that skip layers are the ones that get exploited. Build the full stack, pin your block numbers, and run the fork tests in CI before every release.

Read more

Start now free