DeFi Protocol Testing: How to Test Smart Contracts in Production-Like Conditions

DeFi Protocol Testing: How to Test Smart Contracts in Production-Like Conditions

DeFi protocols operate in an adversarial environment. Every deployed contract is visible to attackers who can manipulate prices, front-run transactions, and chain multiple protocol interactions into a single atomic exploit. Testing in isolation on a blank local chain misses all of this. The only way to test DeFi protocols with confidence is to simulate the exact conditions they will face on mainnet — with real liquidity, real token contracts, and real oracle prices.

Why Blank Local Chains Fail DeFi Tests

When you deploy to a fresh local chain, you get none of the context that defines how your protocol actually behaves:

  • Price oracles return zero or revert because no price feeds exist
  • Liquidity pools are empty, so swap simulations fail
  • Flash loan providers don't exist to test flash loan attack vectors
  • Token contracts you depend on (USDC, WETH, DAI) behave differently from their real implementations

A protocol that passes every test on a blank local chain can still be drained in minutes after mainnet deployment. Mainnet forking closes this gap.

Mainnet Forking

Forking copies the entire state of a live blockchain at a specific block number into your local environment. Your tests can then interact with real contracts, real balances, and real oracle prices.

Setting up a fork in Foundry

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

Or per-test with vm.createFork:

function setUp() public {
    uint256 forkId = vm.createFork(vm.envString("MAINNET_RPC_URL"), 19500000);
    vm.selectFork(forkId);
}

Setting up a fork in Hardhat

// hardhat.config.ts
networks: {
  hardhat: {
    forking: {
      url: process.env.MAINNET_RPC_URL!,
      blockNumber: 19500000,
    },
  },
},

Pinning to a specific block number is important. It makes tests deterministic — prices and balances won't change between runs, so a test that passes today will pass next week.

Impersonating accounts

On a forked network, you can impersonate any address to test admin functions or simulate whale movements:

// Foundry
address whale = 0x28C6c06298d514Db089934071355E5743bf21d60;
vm.startPrank(whale);
IERC20(USDC).transfer(address(pool), 10_000_000e6);
vm.stopPrank();
// Hardhat
await network.provider.request({
  method: "hardhat_impersonateAccount",
  params: ["0x28C6c06298d514Db089934071355E5743bf21d60"],
});
const whale = await ethers.getSigner("0x28C6c06298d514Db089934071355E5743bf21d60");
await usdc.connect(whale).transfer(pool.address, parseUnits("10000000", 6));

Reentrancy Testing

Reentrancy attacks remain one of the most common DeFi exploit patterns despite being well understood. The 2023 Curve Finance reentrancy bug drained over $70 million. Testing for reentrancy requires simulating a malicious contract that calls back into your protocol during execution.

Building a reentrancy attacker contract

contract ReentrancyAttacker {
    IVault target;
    uint256 attackCount;

    constructor(address _target) {
        target = IVault(_target);
    }

    function attack() external payable {
        target.deposit{value: msg.value}();
        target.withdraw(msg.value);
    }

    receive() external payable {
        attackCount++;
        if (attackCount < 3 && address(target).balance >= msg.value) {
            target.withdraw(msg.value);
        }
    }
}

The test

function test_reentrancyProtection() public {
    ReentrancyAttacker attacker = new ReentrancyAttacker(address(vault));

    // Fund the vault with legitimate deposits
    vault.deposit{value: 10 ether}();

    // Attempt the attack with 1 ETH
    vm.expectRevert(); // Should revert if protected
    attacker.attack{value: 1 ether}();

    // Vault balance should be unchanged
    assertEq(address(vault).balance, 10 ether);
}

If your vault doesn't use ReentrancyGuard or the checks-effects-interactions pattern, the attacker will drain it. The test failing means you caught it before deployment.

Cross-function reentrancy

Single-function reentrancy is well-known and most protocols guard against it. Cross-function reentrancy is harder to spot: a callback triggered by function A calls function B, which shares state with function A before effects are committed.

Test for this by having your attacker contract call a different function in the callback:

receive() external payable {
    // Try calling borrow() while withdraw() is still executing
    try target.borrow(address(this), 50 ether) {
        // If this succeeds, you have cross-function reentrancy
    } catch {}
}

Oracle Manipulation Testing

Price oracle manipulation is the attack vector behind dozens of DeFi exploits. An attacker takes a flash loan, uses it to manipulate a spot price oracle, borrows against the inflated price, and exits — all in one transaction.

Simulating a flash loan + oracle attack

function test_oracleManipulationResistance() public {
    // Get the pool's current ETH price from the oracle
    uint256 normalPrice = oracle.getPrice(WETH);

    // Simulate a large swap that would move a spot price oracle
    // Use Uniswap V2 flash swap on forked mainnet
    IUniswapV2Pair pair = IUniswapV2Pair(WETH_USDC_PAIR);

    // Record borrowable amount at normal prices
    uint256 normalBorrowLimit = lending.maxBorrow(address(this), WETH, 1 ether);

    // Manipulate spot price (simulate large one-sided swap)
    vm.store(
        address(pair),
        keccak256(abi.encode(/* reserve slot */)),
        bytes32(uint256(1)) // Extreme reserve imbalance
    );

    // Check that TWAP-based oracle is unaffected
    uint256 postManipPrice = oracle.getPrice(WETH);
    assertApproxEqRel(postManipPrice, normalPrice, 0.01e18); // Within 1%

    // Borrow limit should not have changed
    uint256 postManipBorrowLimit = lending.maxBorrow(address(this), WETH, 1 ether);
    assertEq(postManipBorrowLimit, normalBorrowLimit);
}

Protocols using TWAP oracles (Uniswap V2/V3 time-weighted average prices) are significantly more resistant than spot price oracles. Test that your integration uses TWAP and that the TWAP window is long enough to make manipulation economically infeasible.

Testing oracle staleness

Chainlink oracles have a updatedAt timestamp. If a Chainlink feed goes stale (network issues, low liquidity), your protocol should detect this and reject the price:

function test_staleOracleReverts() public {
    // Fast-forward 2 hours past oracle heartbeat
    vm.warp(block.timestamp + 7201);

    vm.expectRevert("Oracle: stale price");
    lending.borrow(WETH, 1 ether);
}

Flash Loan Attack Simulation

Flash loans enable zero-capital attacks. Any test that doesn't account for flash loan leverage is incomplete for a DeFi protocol.

contract FlashLoanAttacker is IFlashLoanReceiver {
    ILendingPool aave = ILendingPool(AAVE_LENDING_POOL);

    function executeAttack() external {
        address[] memory assets = new address[](1);
        assets[0] = WETH;
        uint256[] memory amounts = new uint256[](1);
        amounts[0] = 10_000 ether; // 10,000 ETH flash loan

        aave.flashLoan(address(this), assets, amounts, ...);
    }

    function executeOperation(...) external returns (bool) {
        // Attempt manipulation with 10,000 ETH
        targetProtocol.depositAndBorrow(10_000 ether);

        // Repay flash loan
        IERC20(WETH).approve(address(aave), amounts[0] + premiums[0]);
        return true;
    }
}

Running this on a forked mainnet against your deployed protocol shows exactly what an attacker would see — and whether your guards hold.

Monitoring Deployed Protocols

Testing before deployment is necessary but not sufficient. On-chain conditions change: liquidity shifts, oracle prices diverge, governance parameters change. Setting up health checks that continuously verify your protocol's key invariants — total collateral value, utilization rates, oracle freshness — catches issues before they become exploits.

HelpMeTest's 24/7 health check monitoring lets you set up checks against your protocol's read functions on a recurring schedule. If totalCollateral() drops below the minimum safe ratio, you get alerted immediately without maintaining any infrastructure.

Building a DeFi Test Suite Checklist

A production-ready DeFi test suite should cover:

Access control

  • All admin functions revert for non-owners
  • Role-based access control enforces separation of privileges
  • Emergency pause/unpause works and restricts the right functions

Economic invariants

  • Total assets always equal the sum of all user balances (accounting identity)
  • Protocol fee accrual matches expected formula across all operations
  • Liquidation threshold and liquidation bonus are consistent

Oracle security

  • TWAP is used, not spot price
  • Stale price detection triggers protocol pause
  • Price deviation checks reject extreme outliers

Reentrancy

  • Single-function reentrancy on all state-changing functions
  • Cross-function reentrancy between related functions
  • Read-only reentrancy on view functions used in calculations

Flash loan resistance

  • Protocol state cannot be manipulated profitably in a single transaction
  • Collateral values use TWAP prices, not spot

Upgrade safety (if proxied)

  • Storage layout unchanged between implementations
  • Initializer cannot be called twice
  • Owner cannot be bricked during upgrade

Running this checklist against a forked mainnet before every deployment is the minimum bar for DeFi protocol security. Anything less is gambling with user funds.

Read more