Testing DeFi Protocols: Forks, Flash Loans, and Invariant Testing
DeFi protocols operate in an adversarial environment where attackers use flash loans, oracle manipulation, and sandwich attacks to drain value. Testing must simulate these attacks explicitly. Mainnet forking provides realistic state, Foundry's invariant tester verifies that core mathematical properties hold across arbitrary transaction sequences, and explicit attack simulations confirm that known exploit vectors are mitigated.
Key Takeaways
Mainnet forking is mandatory for realistic DeFi testing. Real token balances, real Uniswap liquidity, and real Chainlink prices cannot be faithfully mocked. Pin your fork to a specific block number for reproducibility.
AMM invariants must hold across all operations. The constant product formula x * y = k (and its variants) must be verified with invariant tests that call swap, addLiquidity, and removeLiquidity in random sequences.
Flash loan attacks must be simulated explicitly. Every function that changes protocol state should be tested under conditions where the caller has unlimited tokens for the duration of one transaction.
Price oracle manipulation is the most common DeFi exploit vector. Test your protocol's behavior when oracle prices are stale, deviate significantly, or are manipulated via large swaps.
Liquidation tests need extreme market conditions. Your liquidation logic must work correctly at the boundary between healthy and underwater positions, including positions that become unliquidatable due to gas costs.
DeFi Testing Challenges
Testing DeFi protocols is fundamentally different from testing regular smart contracts. A token transfer contract has bounded state: a few mappings and some events. A DeFi protocol operates within an ecosystem of external contracts — price oracles, token contracts, liquidity pools, other protocols — and its correctness depends on how it interacts with all of them.
The adversarial environment adds another dimension. DeFi protocols must be correct not just for well-behaved users but for attackers with arbitrary capital (via flash loans), the ability to manipulate prices, and the ability to choose exactly when to send transactions (MEV).
This guide covers the testing patterns that address these challenges.
Mainnet Forking for Realistic State
Setting Up a Fork
# foundry.toml
[profile.default]
fork_url = <span class="hljs-string">"${MAINNET_RPC_URL}"
fork_block_number = 19_500_000Or in test code:
// test/DeFiProtocol.t.sol
contract DeFiProtocolTest is Test {
// Real mainnet addresses
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address constant WBTC = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599;
address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
// Uniswap V3
address constant UNI_V3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984;
address constant UNI_V3_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
// Chainlink
address constant ETH_USD_FEED = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419;
address constant BTC_USD_FEED = 0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88b;
function setUp() public {
// Fork is already configured in foundry.toml
// Just label addresses for readable traces
vm.label(USDC, "USDC");
vm.label(WETH, "WETH");
vm.label(UNI_V3_ROUTER, "UniV3Router");
}
}Acquiring Forked Token Balances
function _getTokens(address user, address token, uint256 amount) internal {
// deal() works with any ERC20 on a fork — it finds and modifies the balance slot
deal(token, user, amount);
vm.label(user, "testUser");
}
function setUp() public {
address alice = makeAddr("alice");
// Give alice real-world token amounts
_getTokens(alice, USDC, 1_000_000e6); // 1M USDC
_getTokens(alice, WETH, 100e18); // 100 WETH
_getTokens(alice, WBTC, 5e8); // 5 WBTC
}Impersonating Large Holders (Whales)
function _impersonateWhale(address whale) internal returns (address) {
vm.startPrank(whale);
vm.deal(whale, 100 ether); // gas money
return whale;
}
function test_LargeSwapImpact() public {
// Known USDC whale at fork block
address whale = 0x37305B1cD40574E4C5Ce33f8e8306Be057fD7341;
_impersonateWhale(whale);
// Perform large swap — tests slippage and price impact
IERC20(USDC).approve(UNI_V3_ROUTER, type(uint256).max);
// ...
vm.stopPrank();
}Testing AMM Invariants
The Constant Product Formula
Uniswap V2 and its derivatives maintain the invariant x * y = k where x and y are token reserves and k is a constant. Testing your AMM means verifying this holds across all operations.
// src/SimpleAMM.sol
contract SimpleAMM {
IERC20 public tokenA;
IERC20 public tokenB;
uint256 public reserveA;
uint256 public reserveB;
uint256 public totalLiquidity;
mapping(address => uint256) public liquidityOf;
uint256 private constant FEE_NUMERATOR = 997;
uint256 private constant FEE_DENOMINATOR = 1000; // 0.3% fee
event Swap(address indexed user, address tokenIn, uint256 amountIn, uint256 amountOut);
event LiquidityAdded(address indexed provider, uint256 amountA, uint256 amountB, uint256 liquidity);
event LiquidityRemoved(address indexed provider, uint256 amountA, uint256 amountB, uint256 liquidity);
function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut)
public pure returns (uint256)
{
uint256 amountInWithFee = amountIn * FEE_NUMERATOR;
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = (reserveIn * FEE_DENOMINATOR) + amountInWithFee;
return numerator / denominator;
}
function swapAforB(uint256 amountIn) external returns (uint256 amountOut) {
amountOut = getAmountOut(amountIn, reserveA, reserveB);
tokenA.transferFrom(msg.sender, address(this), amountIn);
tokenB.transfer(msg.sender, amountOut);
reserveA += amountIn;
reserveB -= amountOut;
emit Swap(msg.sender, address(tokenA), amountIn, amountOut);
}
// addLiquidity, removeLiquidity omitted for brevity
}Invariant Test Setup
// test/SimpleAMM.invariant.t.sol
contract AMMHandler is Test {
SimpleAMM public amm;
IERC20 public tokenA;
IERC20 public tokenB;
address[] public actors;
uint256 public ghost_totalFeesCollectedA;
uint256 public ghost_totalFeesCollectedB;
constructor(SimpleAMM _amm, IERC20 _a, IERC20 _b) {
amm = _amm;
tokenA = _a;
tokenB = _b;
// Create actors
for (uint256 i = 0; i < 5; i++) {
address actor = makeAddr(string(abi.encodePacked("actor", i)));
actors.push(actor);
deal(address(tokenA), actor, 1_000_000e18);
deal(address(tokenB), actor, 1_000_000e18);
vm.prank(actor);
tokenA.approve(address(amm), type(uint256).max);
vm.prank(actor);
tokenB.approve(address(amm), type(uint256).max);
}
}
function swapAforB(uint256 actorSeed, uint256 amount) external {
address actor = actors[actorSeed % actors.length];
amount = bound(amount, 1, tokenA.balanceOf(actor) / 10);
vm.prank(actor);
amm.swapAforB(amount);
}
function swapBforA(uint256 actorSeed, uint256 amount) external {
address actor = actors[actorSeed % actors.length];
amount = bound(amount, 1, tokenB.balanceOf(actor) / 10);
vm.prank(actor);
amm.swapBforA(amount);
}
function addLiquidity(uint256 actorSeed, uint256 amountA) external {
address actor = actors[actorSeed % actors.length];
amountA = bound(amountA, 1e15, tokenA.balanceOf(actor) / 10);
vm.prank(actor);
amm.addLiquidity(amountA);
}
function removeLiquidity(uint256 actorSeed, uint256 shares) external {
address actor = actors[actorSeed % actors.length];
uint256 actorShares = amm.liquidityOf(actor);
if (actorShares == 0) return;
shares = bound(shares, 1, actorShares);
vm.prank(actor);
amm.removeLiquidity(shares);
}
}
contract AMMInvariantTest is Test {
SimpleAMM public amm;
MockERC20 public tokenA;
MockERC20 public tokenB;
AMMHandler public handler;
function setUp() public {
tokenA = new MockERC20("Token A", "TKA", 18);
tokenB = new MockERC20("Token B", "TKB", 18);
amm = new SimpleAMM(address(tokenA), address(tokenB));
handler = new AMMHandler(amm, tokenA, tokenB);
// Seed initial liquidity
deal(address(tokenA), address(this), 100_000e18);
deal(address(tokenB), address(this), 100_000e18);
tokenA.approve(address(amm), type(uint256).max);
tokenB.approve(address(amm), type(uint256).max);
amm.addLiquidity(100_000e18);
targetContract(address(handler));
}
// The product k = reserveA * reserveB must never decrease after a swap
// (fees cause it to increase slightly; adding/removing liquidity changes it proportionally)
function invariant_constantProductNeverDecreases() public view {
// After fee collection, k only increases
uint256 k = amm.reserveA() * amm.reserveB();
assertGe(k, 100_000e18 * 100_000e18, "k decreased below initial value");
}
// Reserves must always match the contract's actual token balances
function invariant_reservesMatchBalances() public view {
assertEq(
amm.reserveA(),
tokenA.balanceOf(address(amm)),
"reserveA out of sync with actual balance"
);
assertEq(
amm.reserveB(),
tokenB.balanceOf(address(amm)),
"reserveB out of sync with actual balance"
);
}
// Total liquidity shares must be consistent with outstanding LP positions
function invariant_totalLiquidityConsistent() public view {
uint256 sumShares;
for (uint256 i = 0; i < 5; i++) {
address actor = makeAddr(string(abi.encodePacked("actor", i)));
sumShares += amm.liquidityOf(actor);
}
sumShares += amm.liquidityOf(address(this)); // initial LP
assertEq(sumShares, amm.totalLiquidity(), "total liquidity mismatch");
}
}Flash Loan Attack Simulation
Flash loans allow borrowing arbitrary amounts of tokens within a single transaction, repaying before the transaction ends. Every privileged operation must be tested under flash loan conditions.
// A mock flash loan provider for testing
contract MockFlashLoanProvider {
function flashLoan(
IERC20 token,
uint256 amount,
address receiver,
bytes calldata data
) external {
uint256 balanceBefore = token.balanceOf(address(this));
token.transfer(receiver, amount);
IFlashLoanReceiver(receiver).executeOperation(
address(token), amount, 0, data
);
require(
token.balanceOf(address(this)) >= balanceBefore,
"Flash loan not repaid"
);
}
}
// Attack simulation for a lending protocol
contract FlashLoanAttackTest is Test {
LendingProtocol protocol;
MockERC20 collateral;
MockERC20 borrowable;
MockFlashLoanProvider flashLender;
function setUp() public {
collateral = new MockERC20("Collateral", "COL", 18);
borrowable = new MockERC20("Borrowable", "BOR", 18);
protocol = new LendingProtocol(address(collateral), address(borrowable));
flashLender = new MockFlashLoanProvider();
// Fund flash lender
deal(address(borrowable), address(flashLender), 10_000_000e18);
// Fund protocol with borrowable tokens
deal(address(borrowable), address(protocol), 1_000_000e18);
// Create some existing borrowers for realistic state
_seedBorrowers();
}
function test_FlashLoanCannotManipulateCollateralPrice() public {
// Attacker uses flash loan to temporarily inflate collateral price,
// borrows at the inflated price, price returns to normal,
// attacker is now undercollateralized — test that protocol prevents this
address attacker = makeAddr("attacker");
deal(address(collateral), attacker, 100e18);
vm.prank(attacker);
collateral.approve(address(protocol), type(uint256).max);
// Deposit real collateral
vm.prank(attacker);
protocol.deposit(address(collateral), 100e18);
uint256 attackerBorrowableBefore = borrowable.balanceOf(attacker);
// Flash loan won't help — protocol uses TWAP oracle, not spot price
// This test verifies the attack fails silently (borrow amount is limited by oracle price)
flashLender.flashLoan(
borrowable,
1_000_000e18,
attacker,
abi.encode("attack_borrow")
);
// Attacker should not have been able to borrow more than their collateral supports
uint256 attackerBorrowableAfter = borrowable.balanceOf(attacker);
uint256 borrowed = attackerBorrowableAfter - attackerBorrowableBefore;
uint256 maxAllowed = protocol.maxBorrow(attacker);
assertLe(borrowed, maxAllowed, "Flash loan allowed overborrowing");
}
}Price Oracle Manipulation Tests
contract OracleManipulationTest is Test {
// Testing against a Uniswap V2 TWAP oracle
UniswapV2TWAPOracle oracle;
UniswapV2Pair pair;
address constant PAIR_ADDRESS = 0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc; // USDC/WETH
function setUp() public {
// Fork at a specific block
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19_500_000);
oracle = new UniswapV2TWAPOracle(PAIR_ADDRESS, 1800); // 30 min TWAP
pair = UniswapV2Pair(PAIR_ADDRESS);
}
function test_TWAPResistsSpotManipulation() public {
// Record price before manipulation
uint256 priceBefore = oracle.consult(address(WETH), 1e18);
// Simulate a large swap that moves spot price significantly
address manipulator = makeAddr("manipulator");
deal(address(USDC), manipulator, 50_000_000e6); // $50M USDC
vm.startPrank(manipulator);
IERC20(USDC).approve(address(pair), type(uint256).max);
// Large swap dramatically changes spot price
pair.swap(0, getWETHOut(50_000_000e6), manipulator, "");
vm.stopPrank();
// TWAP oracle should still report approximately the same price
// (it accumulates over 30 min, not just the current block)
uint256 priceAfter = oracle.consult(address(WETH), 1e18);
// Price should not have changed more than 1% from manipulation
assertApproxEqRel(priceAfter, priceBefore, 0.01e18);
}
function test_StaleOracleProtection() public {
// Warp forward past the oracle's staleness threshold
vm.warp(block.timestamp + 2 hours);
vm.roll(block.number + 600);
// Protocol should reject stale oracle data
vm.expectRevert(Protocol.StaleOracle.selector);
protocol.borrow(address(WETH), 1e18);
}
}Liquidation Scenario Testing
contract LiquidationTest is Test {
LendingProtocol protocol;
MockChainlinkOracle ethOracle;
function setUp() public {
ethOracle = new MockChainlinkOracle(2000e8); // $2000 ETH
protocol = new LendingProtocol(address(ethOracle));
}
function _createPosition(address user, uint256 collateralEth, uint256 borrowUSDC)
internal
{
vm.deal(user, collateralEth);
vm.startPrank(user);
protocol.depositETH{value: collateralEth}();
protocol.borrow(borrowUSDC);
vm.stopPrank();
}
function test_LiquidationAtHealthFactorBoundary() public {
address borrower = makeAddr("borrower");
address liquidator = makeAddr("liquidator");
// Create position at exactly 150% collateralization (liquidation threshold)
// ETH price = $2000, collateral = 1 ETH = $2000
// Max borrow at 150% threshold = $2000 / 1.5 = $1333 USDC
_createPosition(borrower, 1 ether, 1333e6);
// Should NOT be liquidatable yet (just above threshold)
assertFalse(protocol.isLiquidatable(borrower));
vm.expectRevert(Protocol.PositionHealthy.selector);
vm.prank(liquidator);
protocol.liquidate(borrower, 500e6);
// Drop ETH price to make position undercollateralized
ethOracle.setPrice(1800e8); // $1800 — now at 135% collateral ratio
// Should be liquidatable now
assertTrue(protocol.isLiquidatable(borrower));
// Give liquidator USDC
deal(address(usdc), liquidator, 10_000e6);
vm.prank(liquidator);
usdc.approve(address(protocol), type(uint256).max);
// Liquidate half the debt
uint256 liquidatorEthBefore = liquidator.balance;
vm.prank(liquidator);
protocol.liquidate(borrower, 666e6); // half the debt
// Liquidator received ETH collateral + liquidation bonus (e.g. 5%)
uint256 ethReceived = liquidator.balance - liquidatorEthBefore;
uint256 expectedEth = (666e6 * 1.05e18) / 1800e8; // 5% bonus
assertApproxEqRel(ethReceived, expectedEth, 0.001e18);
}
function test_LiquidationDoesNotLeaveProtocolInsolvent() public {
address borrower = makeAddr("borrower");
address liquidator = makeAddr("liquidator");
_createPosition(borrower, 1 ether, 1300e6);
// Crash ETH price dramatically — position is deeply underwater
ethOracle.setPrice(1000e8); // $1000 — 77% collateralization
assertTrue(protocol.isLiquidatable(borrower));
// Full liquidation
deal(address(usdc), liquidator, 10_000e6);
vm.prank(liquidator);
usdc.approve(address(protocol), type(uint256).max);
vm.prank(liquidator);
protocol.liquidate(borrower, 1300e6);
// Protocol should not have a bad debt > some acceptable threshold
int256 protocolSolvency = protocol.solvencyBuffer();
assertGe(protocolSolvency, -int256(protocol.BAD_DEBT_TOLERANCE()));
}
// Fuzz test: any price drop and position size must leave protocol solvent
function testFuzz_LiquidationAlwaysSolvent(
uint256 collateral,
uint256 borrow,
uint256 newPrice
) public {
collateral = bound(collateral, 0.1 ether, 100 ether);
uint256 maxBorrow = (collateral * 2000) / 1e12 * 2 / 3; // ~66% LTV
borrow = bound(borrow, 1e6, maxBorrow);
newPrice = bound(newPrice, 500e8, 1999e8); // price drop to $500-$1999
address borrower = makeAddr("b");
address liquidator = makeAddr("l");
_createPosition(borrower, collateral, borrow);
ethOracle.setPrice(newPrice);
if (protocol.isLiquidatable(borrower)) {
deal(address(usdc), liquidator, borrow * 2);
vm.prank(liquidator);
usdc.approve(address(protocol), type(uint256).max);
vm.prank(liquidator);
try protocol.liquidate(borrower, borrow) {
// If liquidation succeeds, protocol must be solvent
assertGe(protocol.solvencyBuffer(), -int256(protocol.BAD_DEBT_TOLERANCE()));
} catch {
// Liquidation may fail if position too small (gas > collateral value) — acceptable
}
}
}
}Hardhat Forking with Specific Block Numbers
For reproducible integration tests in Hardhat:
// test/fork/protocol.test.ts
import { reset } from "@nomicfoundation/hardhat-network-helpers";
const FORK_BLOCK = 19_500_000;
before(async function () {
await reset(process.env.MAINNET_RPC_URL!, FORK_BLOCK);
});
it("should interact with real Compound V3", async function () {
const COMET = "0xc3d688B66703497DAA19211EEdff47f25384cdc3"; // cUSDCv3
const comet = await ethers.getContractAt("IComet", COMET);
const [signer] = await ethers.getSigners();
await hre.network.provider.send("hardhat_setBalance", [
signer.address,
ethers.toBeHex(ethers.parseEther("100")),
]);
// Wrap ETH to WETH
const weth = await ethers.getContractAt("IWETH", WETH_ADDRESS);
await weth.deposit({ value: ethers.parseEther("10") });
// Supply WETH as collateral
await weth.approve(COMET, ethers.parseEther("10"));
await comet.supply(WETH_ADDRESS, ethers.parseEther("10"));
// Verify collateral was accepted
const collateral = await comet.collateralBalanceOf(signer.address, WETH_ADDRESS);
expect(collateral).to.be.gt(0n);
});Summary
DeFi protocol testing requires going far beyond standard unit tests. Mainnet forking brings real market conditions — real prices, real liquidity, real protocol state. AMM invariant testing with Foundry verifies that core mathematical properties hold under arbitrary transaction sequences including sequences an adversary might choose. Explicit flash loan simulations confirm that atomic capital attacks are mitigated. Oracle manipulation tests verify that your protocol rejects stale and manipulated prices. Liquidation scenario tests confirm that the system remains solvent even under extreme market conditions.
The protocols that get exploited are almost always the ones where these tests were never written. The ones that survive bear scars — documented attack attempts that became test cases, each one making the next attack harder.