Flash Loans

Difficulty: Intermediate

Flash loans are one of the most innovative and unique primitives in DeFi, with no equivalent in traditional finance. A flash loan allows anyone to borrow any amount of assets from a lending pool without providing collateral, as long as the loan is repaid within the same transaction. If the borrower fails to repay the loan plus a small fee by the end of the transaction, the entire transaction reverts, as if it never happened. This atomicity guarantee makes flash loans risk-free for the lender while providing powerful capabilities to borrowers.

The mechanics work through Ethereum's atomic transaction model. A transaction either succeeds entirely or fails entirely. The flash loan provider (like Aave or dYdX) transfers the requested tokens to the borrower's contract, calls a callback function on the borrower's contract, and then checks that the tokens plus fee have been returned. If the check fails, the entire transaction reverts, including the initial transfer. The borrower's callback function can do anything: arbitrage across DEXs, liquidate undercollateralized positions, refinance loans, or execute complex multi-step DeFi strategies.

Legitimate use cases for flash loans include arbitrage (buying cheap on one DEX and selling expensive on another), liquidations (borrowing the repayment token, liquidating a position, selling the collateral, and repaying the flash loan), collateral swaps (replacing one type of collateral with another without closing and reopening the position), and self-liquidation (repaying your own debt to avoid the liquidation penalty). These operations previously required significant capital, but flash loans democratize access to these strategies.

However, flash loans have also been used as attack vectors. By borrowing large amounts of tokens, attackers can manipulate AMM prices, exploit faulty oracle implementations, and drain protocols that rely on spot prices for critical operations. Notable flash loan attacks include the bZx attacks (2020), Harvest Finance ($34M), and Cream Finance ($130M). The root cause in these attacks was not the flash loan itself but rather the vulnerable protocol's reliance on manipulable price sources.

To implement a flash loan receiver, your contract must implement the callback interface specified by the provider. Aave V3 uses `IFlashLoanSimpleReceiver` with an `executeOperation` callback. The callback receives the borrowed assets, executes your strategy, and must approve the lending pool to pull back the borrowed amount plus fee before returning. The Aave V3 flash loan fee is 0.05% (5 basis points) of the borrowed amount.

Code examples

Flash Loan Receiver (Aave V3 Style)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

// Simplified Aave V3 flash loan interface
interface IPool {
    function flashLoanSimple(
        address receiverAddress,
        address asset,
        uint256 amount,
        bytes calldata params,
        uint16 referralCode
    ) external;
}

interface IFlashLoanSimpleReceiver {
    function executeOperation(
        address asset,
        uint256 amount,
        uint256 premium,
        address initiator,
        bytes calldata params
    ) external returns (bool);
}

// Simplified DEX interface for arbitrage
interface IDEX {
    function swap(address tokenIn, address tokenOut, uint256 amountIn)
        external
        returns (uint256 amountOut);
}

contract FlashLoanArbitrage is IFlashLoanSimpleReceiver {
    IPool public immutable aavePool;
    IDEX public immutable dexA;
    IDEX public immutable dexB;
    address public owner;

    event ArbitrageExecuted(
        address indexed token,
        uint256 borrowed,
        uint256 profit
    );

    constructor(address _pool, address _dexA, address _dexB) {
        aavePool = IPool(_pool);
        dexA = IDEX(_dexA);
        dexB = IDEX(_dexB);
        owner = msg.sender;
    }

    /// @notice Initiate the flash loan arbitrage
    function executeArbitrage(
        address tokenBorrow,
        address tokenIntermediate,
        uint256 amount
    ) external {
        require(msg.sender == owner, "Not owner");

        bytes memory params = abi.encode(tokenIntermediate);

        aavePool.flashLoanSimple(
            address(this),  // receiver
            tokenBorrow,    // asset to borrow
            amount,         // amount to borrow
            params,         // arbitrary data passed to callback
            0               // referral code
        );
    }

    /// @notice Aave calls this function after transferring the loan
    function executeOperation(
        address asset,
        uint256 amount,
        uint256 premium,  // fee to pay
        address initiator,
        bytes calldata params
    ) external override returns (bool) {
        require(msg.sender == address(aavePool), "Caller must be pool");
        require(initiator == address(this), "Initiator must be this");

        address tokenIntermediate = abi.decode(params, (address));

        // Step 1: Swap borrowed tokens for intermediate on DEX A
        IERC20(asset).approve(address(dexA), amount);
        uint256 intermediateAmount = dexA.swap(asset, tokenIntermediate, amount);

        // Step 2: Swap intermediate back to borrowed token on DEX B
        IERC20(tokenIntermediate).approve(address(dexB), intermediateAmount);
        uint256 finalAmount = dexB.swap(tokenIntermediate, asset, intermediateAmount);

        // Step 3: Ensure profit covers the flash loan fee
        uint256 totalOwed = amount + premium;
        require(finalAmount >= totalOwed, "Arbitrage not profitable");

        // Step 4: Approve Aave to pull back the loan + fee
        IERC20(asset).approve(address(aavePool), totalOwed);

        // Profit stays in this contract
        uint256 profit = finalAmount - totalOwed;
        emit ArbitrageExecuted(asset, amount, profit);

        return true;
    }

    /// @notice Withdraw profits
    function withdrawProfits(address token) external {
        require(msg.sender == owner, "Not owner");
        uint256 balance = IERC20(token).balanceOf(address(this));
        IERC20(token).transfer(owner, balance);
    }
}

This contract borrows tokens via an Aave flash loan, performs a two-step arbitrage across two DEXs, and repays the loan plus fee within the same transaction. If the arbitrage is not profitable, the require statement causes the entire transaction to revert.

Flash Loan Provider (Simplified)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IFlashBorrower {
    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32);
}

/// @notice Simplified flash loan provider (EIP-3156 style)
contract FlashLender {
    uint256 public constant FEE_BPS = 5; // 0.05%
    uint256 public constant BPS = 10_000;
    bytes32 public constant CALLBACK_SUCCESS = keccak256("ERC3156FlashBorrower.onFlashLoan");

    mapping(address => bool) public supportedTokens;

    event FlashLoan(
        address indexed borrower,
        address indexed token,
        uint256 amount,
        uint256 fee
    );

    constructor(address[] memory tokens) {
        for (uint256 i = 0; i < tokens.length; i++) {
            supportedTokens[tokens[i]] = true;
        }
    }

    function maxFlashLoan(address token) external view returns (uint256) {
        if (!supportedTokens[token]) return 0;
        return IERC20(token).balanceOf(address(this));
    }

    function flashFee(address token, uint256 amount) public view returns (uint256) {
        require(supportedTokens[token], "Token not supported");
        return (amount * FEE_BPS) / BPS;
    }

    function flashLoan(
        IFlashBorrower borrower,
        address token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool) {
        require(supportedTokens[token], "Token not supported");
        uint256 fee = flashFee(token, amount);
        uint256 balanceBefore = IERC20(token).balanceOf(address(this));

        // Transfer tokens to borrower
        IERC20(token).transfer(address(borrower), amount);

        // Call borrower's callback
        require(
            borrower.onFlashLoan(msg.sender, token, amount, fee, data) == CALLBACK_SUCCESS,
            "Callback failed"
        );

        // Verify repayment (borrower must have approved this contract)
        uint256 balanceAfter = IERC20(token).balanceOf(address(this));
        require(
            balanceAfter >= balanceBefore + fee,
            "Flash loan not repaid"
        );

        emit FlashLoan(address(borrower), token, amount, fee);
        return true;
    }
}

This shows the lender side: it transfers tokens to the borrower, calls the callback, and then verifies that the original amount plus fee has been returned. If not, the entire transaction reverts. This follows the EIP-3156 flash loan standard.

Key points

Concepts covered

Flash Loan, Atomic Transaction, Arbitrage, Aave