DeFi testing combines unit testing, fork testing, invariant testing, and fuzzing to verify smart contract behavior under adversarial conditions. Essential tools: Foundry (cheatcodes, invariant runner), Hardhat (TypeScript, fork mainnet), and specialized frameworks like Echidna for property-based testing. Test oracle manipulation, flash loans, reentrancy, and economic invariants before mainnet deployment.
DeFi protocols secure billions in TVL, yet testing remains the most overlooked phase of development. Unlike traditional software, DeFi bugs are irreversible and instantly exploitable. This guide covers the complete testing stack: from unit tests for individual functions to invariant testing that mathematically proves protocol properties hold under all conditions. We'll use Foundry as the primary tool (fastest, best cheatcodes) with Hardhat alternatives, and show real attack vectors from major DeFi hacks (Euler, Mango, Cream) as test cases.
Diagram - DeFi Testing Stack Architecture
1. DeFi Testing Fundamentals
Why DeFi testing differs from Web2 testing comes down to three properties: immutability (no hotfixes), composability (your contract interacts with unknown protocols), and economic attacks (profit-driven adversaries). A single oversight — a missing reentrancy guard, an unchecked oracle price, a rounding error in share accounting — can drain the entire protocol.
The testing pyramid for DeFi has five layers, each catching what the previous misses:
- Unit tests — individual functions, pure logic, math operations
- Integration tests — multi-contract flows, protocol interactions
- Fork tests — mainnet state replay, adversarial scenarios, historical exploits
- Invariant tests — mathematical properties that must ALWAYS hold
- Fuzzing — randomized input exploration for edge cases
Flowchart - Unit Test Coverage for DeFi Primitives
| Tool | Best For | Cheatcodes | Invariant Runner | Fuzzing | Learning Curve |
|---|---|---|---|---|---|
| Foundry | Speed, DeFi-native, fork/invariant/fuzz all-in-one | ✅ Excellent | ✅ Built-in | ✅ Built-in | Low (Solidity) |
| Hardhat | TypeScript teams, plugin ecosystem | ❌ Manual | ❌ External | ❌ External | Medium (JS/TS) |
| Echidna | Property-based, stateful fuzzing | N/A | N/A | ✅ Specialized | High (Haskell-like) |
| Medusa | High-throughput fuzzing, CI/CD | N/A | N/A | ✅ Fastest | High |
| Slither | Static analysis, vulnerability detection | N/A | N/A | N/A | Low (CLI) |
Featured Snippet target: "What is the best tool for DeFi testing?" → Foundry for speed/cheatcodes, Echidna for property-based testing.
2. Setting Up Your Testing Environment
Foundry Installation and Project Structure
`bash
curl -L https://foundry.paradigm.xyz | bash foundryup
forge init defi-testing-demo cd defi-testing-demo
`
Hardhat Alternative Setup
`bash npm init -y npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox npx hardhat init `
Configuring Fork Testing
Fork testing requires an RPC endpoint with archive node access. Recommended providers:
- Alchemy — generous free tier, Ethereum mainnet + L2s
- QuickNode — multi-chain, good for cross-chain testing
- Chainstack — reliable archive access
Configure in foundry.toml:
`toml [profile.ci] fork_url = "https://eth-mainnet.alchemyapi.io/v2/YOUR_KEY" fork_block_number = 19_500_000 # Pin to specific block for reproducibility
[fuzz] runs = 10000 max_test_rejects = 10000
[invariant] runs = 1000 depth = 50 fail_on_revert = false `
CI/CD Pipeline
GitHub Actions workflow (.github/workflows/test.yml):
`yaml name: DeFi Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps:
- uses: actions/checkout@v4
- uses: foundry-rs/foundry-toolchain@v1
with: version: nightly
- run: forge build
- run: forge test -vvv
- run: forge test --invariant --invariant-runs 1000
- run: forge coverage --report lcov
- uses: codecov/codecov-action@v3
`
Diagram - DeFi Testing Stack Architecture
3. Unit Testing DeFi Primitives
Testing ERC20/ERC721 Compliance
Use OpenZeppelin test helpers for standard compliance:
`solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20;
import "forge-std/Test.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; import "@openzeppelin/contracts/test/helpers/ERC20TestHelpers.sol";
contract MyTokenTest is Test, ERC20TestHelpers { MyToken token; address alice = makeAddr("alice"); address bob = makeAddr("bob");
function setUp() public { token = new MyToken("Test", "TST"); }
function testTransfer() public { token.mint(alice, 1000 ether); testTransferFrom(alice, bob, 100 ether); }
function testPermit() public { token.mint(alice, 1000 ether); testPermit(alice, address(this), 100 ether, 0, 0, 0); } } `
AMM Math: Constant Product Formula
The core invariant: x * y = k (reserves product constant, minus fees).
`solidity contract UniswapV2MathTest is Test { function testConstantProduct() public { uint256 reserveA = 1000 ether; uint256 reserveB = 2000 ether; uint256 k = reserveA * reserveB;
// Swap 100 A for B uint256 amountIn = 100 ether; uint256 amountOut = (amountIn * reserveB) / (reserveA + amountIn);
// Verify invariant holds (with 0.3% fee) uint256 newReserveA = reserveA + amountIn; uint256 newReserveB = reserveB - amountOut; uint256 fee = (amountIn 3) / 1000; uint256 expectedK = (reserveA reserveB) + (fee * reserveB);
assertApproxEqAbs(newReserveA * newReserveB, expectedK, 1); }
function testSwapRounding() public { // Edge case: tiny swaps should not drain reserves uint256 reserveA = 1 ether; uint256 reserveB = 1 ether; uint256 amountOut = (1 wei * reserveB) / (reserveA + 1 wei); assertEq(amountOut, 0); // Too small, correctly returns 0 } } `
Lending Protocol: Collateral Factor & Liquidation
`solidity contract LendingMathTest is Test { // Collateral factor: 75% = borrow up to 75% of collateral value // Liquidation threshold: 80% = liquidate when debt > 80% of collateral
function testMaxBorrow() public { uint256 collateralValue = 1000 ether; // $1000 worth of ETH uint256 collateralFactor = 7500; // 75% in basis points uint256 maxBorrow = (collateralValue * collateralFactor) / 10000; assertEq(maxBorrow, 750 ether); }
function testLiquidationTrigger() public { uint256 collateralValue = 1000 ether; uint256 debt = 850 ether; // 85% — above 80% threshold uint256 liquidationThreshold = 8000; // 80%
uint256 healthFactor = (collateralValue liquidationThreshold) / (debt 10000); assertLt(healthFactor, 10000); // Health factor < 1 = liquidatable } } `
Vault/Share Accounting: Deposit/Withdraw Rounding
`solidity contract VaultAccountingTest is Test { Vault vault; ERC20Mock asset;
function setUp() public { asset = new ERC20Mock("Asset", "AST"); vault = new Vault(asset); asset.mint(address(this), 10000 ether); }
function testDepositWithdrawRoundTrip() public { uint256 depositAmount = 1000 ether; asset.approve(address(vault), depositAmount);
uint256 sharesBefore = vault.totalSupply(); uint256 assetsBefore = asset.balanceOf(address(vault));
vault.deposit(depositAmount, address(this));
uint256 sharesAfter = vault.totalSupply(); uint256 assetsAfter = asset.balanceOf(address(vault));
// Shares received should match assets deposited (first depositor gets 1:1) assertEq(sharesAfter - sharesBefore, depositAmount); assertEq(assetsAfter - assetsBefore, depositAmount); }
function testSharePriceManipulation() public { // Attack: donate assets directly to vault to inflate share price asset.mint(address(vault), 1000 ether); // Donation
// Victim deposits — gets fewer shares due to inflated price asset.mint(alice, 100 ether); asset.approve(address(vault), 100 ether, alice); vm.prank(alice); vault.deposit(100 ether, alice);
// Alice gets fewer shares than she should uint256 aliceShares = vault.balanceOf(alice); assertLt(aliceShares, 100 ether); // Rounding loss! } } `
Run with: forge test --match-contract VaultAccountingTest -vv
4. Fork Testing: Mainnet Replay & Adversarial Scenarios
Sequence - Fork Test Execution Flow
Fork testing lets you replay mainnet state at any block and simulate transactions against it. Foundry's cheatcodes make this powerful:
`solidity contract ForkTest is Test { function setUp() public { // Create fork at block 19_500_000 (pre-exploit) vm.createFork("https://eth-mainnet.alchemyapi.io/v2/KEY", 19_500_000); }
function testEulerFinanceExploit() public { // Euler exploit: donate → borrow → manipulate → liquidate // 1. Attacker donates eToken to protocol (inflates totalAssets) // 2. Borrows against inflated collateral // 3. Manipulates price oracle // 4. Liquidates own position at profit
address attacker = makeAddr("attacker"); address euler = 0x...; // Euler lending contract
vm.prank(attacker); // ... exploit steps replayed from tx trace
// Verify attacker profits assertGt(eth.balanceOf(attacker), initialBalance); }
function testFlashLoanAttack() public { // Single-transaction: borrow → manipulate → repay address pool = 0x...; // Aave/Uniswap pool address attacker = makeAddr("attacker");
vm.startPrank(attacker); // Execute flash loan // Manipulate oracle/price // Repay loan + profit vm.stopPrank(); }
function testOracleManipulation() public { // Chainlink stale price: don't update for hours // Pyth TWAP: manipulate over multiple slots // Custom oracle: direct price setter
// Simulate Chainlink stale price vm.warp(block.timestamp + 2 hours); // Past heartbeat // Price now stale — protocol uses wrong value }
function testMEVSandwich() public { // Frontrun: detect pending swap, buy before, sell after // Backrun: detect liquidation, repay debt, claim collateral
vm.expectRevert(); // Victim tx reverts due to slippage // ... sandwich execution } } `
Oracle Manipulation Testing Deep Dive
Chainlink Stale Price: `solidity function testChainlinkStalePrice() public { AggregatorV3Interface feed = AggregatorV3Interface(0x...);
// Normal price (, int256 price,,,) = feed.latestRoundData(); assertGt(price, 0);
// Fast-forward past heartbeat (typically 1-24 hours) vm.warp(block.timestamp + 25 hours);
// Price now stale — roundId hasn't changed (uint80 roundId,,,,) = feed.latestRoundData(); // Protocol should reject or use fallback } `
Pyth TWAP Deviation: `solidity function testPythTWAPManipulation() public { // Pyth publishes price every slot // TWAP = average over N slots // Attacker manipulates N consecutive slots
for (uint i = 0; i < 10; i++) { vm.roll(block.number + 1); // Submit manipulated price via Pyth wormhole pythMock.updatePrice(manipulatedPrice); }
// TWAP now reflects manipulated prices uint256 twap = pythMock.getTWAP(10); assertApproxEqAbs(twap, manipulatedPrice, 1%); } `
5. Invariant Testing: Mathematical Guarantees
Diagram - Invariant Testing Architecture
What are invariants? Properties that must ALWAYS hold, regardless of user actions, market conditions, or attack vectors. Unlike unit tests (specific inputs → expected outputs), invariant tests say: "For ANY sequence of valid operations, these properties remain true."
DeFi Invariant Catalog
| Protocol Type | Critical Invariants |
|---|---|
| AMM (Uniswap V2) | reserve0 * reserve1 ≥ k_last (with fees), reserve0 > 0 && reserve1 > 0, no overflow in getAmountOut |
| AMM (Uniswap V3) | totalLiquidity = Σ position.liquidity, sqrtPriceX96 within tick bounds, fee growth monotonic |
| Lending (Aave/Compound) | totalAssets ≥ totalBorrows, collateralRatio ≥ liquidationThreshold for all positions, supplyIndex monotonic |
| Vaults (ERC4626) | totalAssets() ≥ convertToAssets(totalSupply()), previewDeposit(shares) ≥ assets, no share price manipulation via donation |
| Staking | totalShares * pricePerShare = totalAssets, rewardsDistributed ≤ rewardsAvailable, no double-claim |
| Governance | quorumReached → executionDelay → timelock → execute, proposalThreshold ≤ votingPower, no double-vote |
Writing Invariant Tests in Foundry
`solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20;
import "forge-std/Test.sol"; import "../src/UniswapV2Pair.sol";
contract UniswapV2InvariantTest is Test { UniswapV2Pair pair; ERC20Mock token0; ERC20Mock token1; address alice = makeAddr("alice"); address bob = makeAddr("bob");
function setUp() public { token0 = new ERC20Mock("Token0", "TK0"); token1 = new ERC20Mock("Token1", "TK1"); pair = new UniswapV2Pair();
// Initialize with liquidity token0.mint(alice, 10000 ether); token1.mint(alice, 10000 ether); vm.prank(alice); token0.approve(address(pair), type(uint256).max); token1.approve(address(pair), type(uint256).max); pair.initialize(alice); // Sets up reserves }
// Invariant: reserve product never decreases (accounting for fees) function invariant_reserveProductNonDecreasing() public view { (uint112 reserve0, uint112 reserve1,) = pair.getReserves(); uint256 k = uint256(reserve0) * uint256(reserve1); // kLast tracks reserve product at last sync // After swaps with fees, k should increase assertGe(k, pair.kLast()); }
// Invariant: reserves never zero (prevents division by zero) function invariant_reservesNonZero() public view { (uint112 reserve0, uint112 reserve1,) = pair.getReserves(); assertGt(reserve0, 0); assertGt(reserve1, 0); }
// Invariant: swap output matches formula function invariant_swapFormulaCorrect() public view { (uint112 reserve0, uint112 reserve1,) = pair.getReserves();
// Test various input amounts uint256[] memory testAmounts = [1 ether, 10 ether, 100 ether, 1000 ether]; for (uint i = 0; i < testAmounts.length; i++) { uint256 amountIn = testAmounts[i]; if (amountIn >= reserve0) continue; // Skip invalid
uint256 expectedOut = (amountIn 997 reserve1) / (reserve0 1000 + amountIn 997); uint256 actualOut = pair.getAmountOut(amountIn, reserve0, reserve1); assertEq(actualOut, expectedOut); } }
// Invariant: total supply equals sum of user balances function invariant_totalSupplyEqualsSum() public view { // This requires tracking all holders — use for simpler contracts // For pair, totalSupply = liquidity tokens minted assertEq(pair.totalSupply(), pair.balanceOf(alice) + pair.balanceOf(bob) + ...); } } `
Running Invariant Tests
`bash
forge test --invariant --invariant-depth 50 --invariant-runs 1000
forge test --invariant --match-contract UniswapV2InvariantTest
forge test --invariant -vvvv
forge test --invariant --invariant-depth 10 --match-test invariant_reserveProductNonDecreasing -vvvv `
Interpreting Results
When an invariant fails, Foundry provides a counterexample — a sequence of calls that violates the property. The output shows:
- Call sequence that led to violation
- State before/after each call
- Shrinking — Foundry tries to minimize the counterexample
- Replay — Add the sequence as a unit test to debug
`bash
[FAIL. Reason: invariant_reserveProductNonDecreasing() failed] Counterexample:
- swap(token0, 100 ether) → reserve0=1100, reserve1=909
- swap(token1, 500 ether) → reserve0=1000, reserve1=1000 // k decreased!
- sync() → kLast updated incorrectly
`
6. Fuzzing & Property-Based Testing
Diagram - Fuzzing vs Invariant Testing Comparison
Stateless Fuzzing (Foundry)
For pure functions, stateless fuzzing generates random inputs:
`solidity contract MathFuzzTest is Test { // Foundry automatically fuzzes any test function with parameters function testGetAmountOutFuzz(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) public { // Filters: skip invalid inputs vm.assume(reserveIn > 0); vm.assume(reserveOut > 0); vm.assume(amountIn > 0); vm.assume(amountIn < reserveIn); // Can't swap more than reserve
uint256 expected = (amountIn 997 reserveOut) / (reserveIn 1000 + amountIn 997); uint256 actual = UniswapV2Library.getAmountOut(amountIn, reserveIn, reserveOut); assertEq(actual, expected); }
// Run: forge test --match-test testGetAmountOutFuzz --fuzz-runs 100000 } `
Stateful Fuzzing (Echidna/Medusa)
For protocol-level properties requiring state:
`yaml
config: testMode: "assertion" maxTx: 50 maxBlocks: 100 corpusDir: "./corpus"
targets:
- "LendingProtocol"
dictionary:
- "deposit"
- "withdraw"
- "borrow"
- "repay"
- "liquidate"
`
`solidity // Echidna test contract contract LendingProtocolFuzz is Test { LendingProtocol protocol; ERC20Mock collateral; ERC20Mock debt;
function setUp() public { collateral = new ERC20Mock("COL", "COL"); debt = new ERC20Mock("DEBT", "DEBT"); protocol = new LendingProtocol(collateral, debt); }
// Property: health factor never < 1 for non-liquidated positions function echidna_healthFactor() public view returns (bool) { // Check all positions for (uint i = 0; i < protocol.positionCount(); i++) { Position memory pos = protocol.positions(i); if (pos.collateral > 0) { uint256 hf = protocol.healthFactor(pos.user); if (hf < 1e18 && !pos.liquidated) { return false; // Violated! } } } return true; }
// Property: total assets always ≥ total borrows function echidna_solvency() public view returns (bool) { return protocol.totalAssets() >= protocol.totalBorrows(); } } `
Run: echidna-test LendingProtocolFuzz.sol --config echidna.yaml
When to Use Fuzzing vs Invariant Testing
| Aspect | Fuzzing | Invariant Testing |
|---|---|---|
| Goal | Find edge cases, crashes | Prove properties hold |
| Input | Random (guided by corpus) | Random sequences of calls |
| State | Stateless (mostly) | Stateful |
| Output | Crashing inputs | Counterexample sequences |
| Best For | Pure math, encoding/decoding | Economic invariants, complex flows |
| Tools | Foundry (builtin), Echidna | Foundry (builtin), Medusa |
Use both: Fuzzing finds bugs in isolated functions; invariant testing proves protocol-level properties.
7. Cross-Chain & Upgradeable Protocol Testing
Diagram - Cross-Chain Testing Architecture
Testing Bridge Messages
`solidity contract BridgeMessageTest is Test { Bridge bridge; MockEndpoint endpoint;
function setUp() public { endpoint = new MockEndpoint(); bridge = new Bridge(endpoint); }
function testMessagePassing() public { address user = makeAddr("user"); bytes memory payload = abi.encodeWithSelector( ILendingProtocol.deposit.selector, 100 ether );
// Send message from source chain vm.prank(user); bridge.sendMessage(chainIdB, address(protocolB), payload);
// Verify message queued assertEq(endpoint.queuedMessages.length, 1);
// Simulate relay on destination chain endpoint.relayMessage(0);
// Verify execution on destination assertEq(protocolB.totalAssets(), 100 ether); }
function testReplayProtection() public { // Each message has unique nonce // Replay attempt should fail bytes memory payload = abi.encodeWithSelector(...);
bridge.sendMessage(chainIdB, address(protocolB), payload); endpoint.relayMessage(0);
vm.expectRevert("AlreadyExecuted"); endpoint.relayMessage(0); // Duplicate nonce } } `
Omnichain Contracts (LayerZero, Wormhole, Axelar)
`solidity contract OmnichainTest is Test { // Mock LayerZero endpoint MockLZEndpoint lzEndpoint; OmnichainVault vaultA; OmnichainVault vaultB;
function testCrossChainDeposit() public { // User deposits on Chain A vm.prank(alice); vaultA.depositCrossChain(100 ether, chainIdB, address(vaultB));
// Message sent via LayerZero // Simulate receipt on Chain B lzEndpoint.simulateReceipt(chainIdA, address(vaultA), payload);
// Vault B should credit user assertEq(vaultB.balanceOf(alice), 100 ether); } } `
Proxy/Upgradeable Testing
`solidity contract UpgradeableTest is Test { ProxyAdmin admin; TransparentUpgradeableProxy proxy; VaultV1 vaultV1; VaultV2 vaultV2;
function setUp() public { admin = new ProxyAdmin(); vaultV1 = new VaultV1(); proxy = new TransparentUpgradeableProxy( address(vaultV1), admin, "" // No initializer ); }
function testStorageLayoutCollision() public { // Deploy V2 with new state variable vaultV2 = new VaultV2();
// Upgrade vm.prank(address(admin)); proxy.upgradeTo(address(vaultV2));
// Verify V1 state preserved IVault(proxy).deposit(100 ether, alice); assertEq(IVault(proxy).balanceOf(alice), 100 ether);
// New V2 functionality works IVaultV2(proxy).newFeature(); }
function testInitializerGuard() public { // V2 initializer should not be callable twice vm.expectRevert("Initializable: already initialized"); IVaultV2(proxy).initialize(); } } `
Governance Timelock Testing
`solidity contract GovernanceTimelockTest is Test { TimelockController timelock; Governor governor; Vault vault;
function testProposalLifecycle() public { // 1. Propose bytes memory calldata = abi.encodeWithSelector( IVault.setFee.selector, 500 // 5% new fee ); uint256 proposalId = governor.propose( [address(vault)], [0], [calldata], "Update fee to 5%" );
// 2. Vote vm.warp(votingDelay + 1); vm.prank(whale); governor.castVote(proposalId, 1); // For
// 3. Queue (after voting period) vm.warp(votingPeriod + votingDelay + 1); governor.queue(proposalId);
// 4. Execute (after timelock delay) vm.warp(timelockDelay + 1); governor.execute(proposalId);
// Verify assertEq(vault.fee(), 500); } } `
8. Gas Profiling & Performance Testing
`bash
forge test --gas-report
forge snapshot
forge snapshot --diff # Shows gas increases/decreases
forge loadtest --rpc-url $RPC_URL --tx-count 1000 --gas-limit 30000000 `
Gas Optimization Checklist
| Pattern | Optimization | Gas Saved |
|---|---|---|
for (uint i=0; i | Cache length: uint len = arr.length | ~3,000/loop |
storage in loops | Cache to memory | ~20,000/access |
require(msg.sender == owner) | Custom error: error NotOwner() | ~200/revert |
string error messages | Custom errors with params | ~100/revert |
Multiple SSTORE to same slot | Batch in memory, single write | ~20,000/write |
unchecked { x + y } | Skip overflow checks (when safe) | ~50/op |
9. Common Mistakes & Anti-Patterns
- Testing only happy paths — Never test failure modes (reverts, edge cases). Every
requireneeds a test that triggers it. - Mocking oracles incorrectly — Use real price feeds in fork tests; mocks miss manipulation vectors.
- Ignoring composability — Test with actual protocol integrations, not mocks. Your contract lives in an ecosystem.
- Skipping invariant testing — Unit tests cannot catch economic bugs. Invariants are your safety net.
- No CI/CD — Tests that don't run on every PR are useless. GitHub Actions + Foundry = 5 min setup.
- Hardcoding addresses — Use
vm.label, config files, and environment variables for multi-chain. - Forgetting upgradeability — Test proxy admin, initializer guards, storage gaps, UUPS vs Transparent.
- Ignoring gas costs — A function that works but costs 5M gas is broken. Profile every release.
10. FAQ
What is the difference between fork testing and invariant testing? Fork testing replays mainnet state to simulate specific scenarios (exploits, oracle states). Invariant testing mathematically proves properties hold for ALL possible state transitions. Use fork tests for known attacks; invariants for unknown ones.
How many fuzz runs are sufficient for DeFi contracts? Minimum 10,000 runs for stateless fuzzing; 1,000+ invariant runs with depth 50+. For high-value protocols, run nightly with 100,000+ fuzz runs and 10,000+ invariant runs.
Can I test DeFi protocols without mainnet RPC access? Yes — use public RPCs (Alchemy free tier), local anvil forks (anvil --fork-url), or testnet forks (Sepolia, Arbitrum Sepolia). For CI, use ephemeral forks via GitHub Actions.
What invariants should every AMM satisfy?
reserve0 * reserve1 ≥ kLast(with fees)reserve0 > 0 && reserve1 > 0(no zero reserves)getAmountOutmatches constant product formulatotalSupplyequals sum of LP positions- Fee growth monotonically increases per LP position
How do I test upgradeable DeFi contracts safely?
- Verify storage layout compatibility (no collisions)
- Test initializer guards (cannot re-initialize)
- Test proxy admin permissions (only governance)
- Simulate upgrade → verify state preserved → test new features
- Test emergency pause/upgrade paths
Ready to audit your DeFi protocol? Our security team specializes in invariant testing, fork replay, and formal verification. [Book a consultation](/security-audit)
