Flash loans are the purest expression of what makes Ethereum programmable money: you can borrow millions of dollars with no collateral and no credit check, provided you give it all back before the transaction ends. EIP-3156 is the attempt to give that primitive one standard interface, so that a borrower contract written once can take liquidity from any compliant lender. This post covers the primitive itself, the two interfaces with real Solidity, a minimal borrower, the security model behind the odd-looking magic return value, and the honest adoption picture.

What a flash loan actually is

A normal loan is secured by collateral because the lender has no other way to guarantee repayment. A flash loan is secured by atomicity instead. Everything happens inside a single Ethereum transaction: the lender transfers the tokens, your contract does whatever it wants with them, and before the transaction can complete, the lender takes the principal plus a fee back. If that repayment fails, even by one wei, the whole transaction reverts, unwinding every state change inside it, including the original transfer. From the chain’s point of view the loan never happened.

That is the entire trick. The lender cannot lose principal, because the only world in which the borrower keeps the money is a world that never gets committed. The EVM’s all-or-nothing execution replaces collateral, which is why loan size is bounded by pool liquidity rather than by anything about the borrower. The practical uses follow: collateral swaps and refinancing, liquidations where the liquidator does not hold the repay asset, and arbitrage, where the loan supplies working capital for a swap cycle.

Atomic flash loan lifecycle inside one Ethereum transaction: borrow with zero collateral, act freely across protocols, then the lender pulls amount plus fee; full repayment commits state while a shortfall reverts everything so the loan never existed on chain

Why flash loans needed a standard

By late 2020 the pattern had at least four incompatible dialects. Aave’s pool called executeOperation on your receiver and expected repayment ready at the end. dYdX had no flash loan function at all; searchers abused its operate() batch actions to withdraw and redeposit within one call. Uniswap V2 hid the capability inside swap() itself: pass nonempty data and the pair optimistically sends tokens out and calls uniswapV2Call on you before checking its invariant. Same primitive, three different callback names, argument lists, fee conventions, and repayment mechanics.

The EIP’s motivation section names Aave, dYdX, Uniswap, and Yield as early adopters that “have produced different interfaces and different use patterns”. Every integration was bespoke, every audit reasoned about a new callback, and you could not write one borrower and point it at whichever lender was cheapest or deepest that block. EIP-3156, authored by Alberto Cuesta Cañada and collaborators in November 2020 and now Final, standardizes both sides: a lender interface for discovering and initiating loans, and a borrower interface for receiving them.

The EIP-3156 interfaces

The lender side is three functions:

interface IERC3156FlashLender {
    function maxFlashLoan(address token) external view returns (uint256);

    function flashFee(address token, uint256 amount)
        external view returns (uint256);

    function flashLoan(
        IERC3156FlashBorrower receiver,
        address token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool);
}

maxFlashLoan returns the largest loan currently available for a token, and returns 0 rather than reverting for unsupported tokens, so you can probe lenders cheaply. flashFee quotes the fee for a given amount and must revert for unsupported tokens. flashLoan does the work: it transfers amount of token to the receiver, invokes the callback, and afterwards pulls amount + fee back from the receiver via transferFrom. The data parameter is opaque bytes passed straight through to the callback, which is how you smuggle strategy parameters or swap paths into the loan without storage writes.

The borrower side is a single callback:

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

By the time onFlashLoan runs, the tokens are already in your contract. When your logic is done you do not send them back; you approve the lender for amount + fee and return the magic value keccak256("ERC3156FlashBorrower.onFlashLoan"). The lender checks that return value, then pulls repayment itself. Both checks happen inside flashLoan, so a wrong return or an insufficient balance reverts everything.

EIP-3156 lender and borrower callback flow: flashLoan transfers the amount to the borrower and invokes onFlashLoan; the borrower runs its strategy, approves amount plus fee and returns the keccak256 magic value, then the lender pulls repayment with transferFrom; any failure reverts the whole transaction

A minimal borrower, walked through

Here is the smallest useful EIP-3156 borrower, with the two checks the spec insists on:

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

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC3156FlashLender} from "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol";
import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";

contract FlashBorrower is IERC3156FlashBorrower {
    bytes32 private constant CALLBACK_SUCCESS =
        keccak256("ERC3156FlashBorrower.onFlashLoan");

    IERC3156FlashLender public immutable lender;
    address public immutable owner;

    constructor(IERC3156FlashLender lender_) {
        lender = lender_;
        owner = msg.sender;
    }

    function borrow(address token, uint256 amount, bytes calldata data) external {
        require(msg.sender == owner, "not owner");
        uint256 repayment = amount + lender.flashFee(token, amount);
        // Approve exactly what this loan will pull back, no standing allowance.
        IERC20(token).approve(address(lender), repayment);
        lender.flashLoan(this, token, amount, data);
    }

    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external override returns (bytes32) {
        require(msg.sender == address(lender), "untrusted lender");
        require(initiator == address(this), "untrusted initiator");

        // The contract now holds `amount` of `token`.
        // Strategy goes here: swaps, liquidation, refinancing,
        // decoded from `data`. It must end with at least
        // amount + fee of `token` sitting in this contract.

        return CALLBACK_SUCCESS;
    }
}

Your borrow function quotes the fee, approves exactly the repayment, and calls flashLoan. The lender transfers the tokens and calls onFlashLoan. The two require statements are not decoration: the first ensures only the real lender can trigger the callback, the second that the contract only honors loans it started itself. Then the strategy runs, the magic constant comes back, and the lender’s transferFrom closes the loop. If the strategy failed to produce amount + fee, the pull reverts and the whole transaction, borrow included, evaporates.

The security model, and where integrations go wrong

The magic return value looks like ceremony until you consider what a lender is: a contract that will call an arbitrary function on an arbitrary address and then transferFrom money out of it. Without the return-value check, a contract that merely fails to revert on onFlashLoan, a proxy with a permissive fallback, say, could be named as receiver by an attacker and, given any standing allowance, bled through fees on every forced loan. Requiring the exact keccak256 hash turns “did not revert” into “explicitly consented to being a flash borrower”, which a fallback cannot fake.

Pull-based repayment serves the same defensive posture. The lender never trusts the borrower to push tokens back; it takes them, and reverts if it cannot. The cost is that the allowance becomes part of your attack surface, which is where most real integration mistakes live. A standing type(uint256).max approval instead of a per-loan one lets anyone who can trigger your callback drain value through fees or forced loans. Skipping the msg.sender check lets anyone call your callback with fabricated arguments. Skipping the initiator check lets an attacker start a loan to your contract with attacker-chosen data, running your strategy on their terms. And hardcoding a zero fee because the lender charges nothing today breaks the day governance flips the fee on.

Flash loan attacks, soberly

“Flash loan attack” is one of the most searched phrases in DeFi security, and it is mostly a misnomer. A flash loan has never broken a correct protocol. It removes the capital barrier in front of an existing bug: an exploit that once required a nine-figure balance becomes available to anyone with gas money. The loan is the amplifier, not the vulnerability.

The classic shape is oracle manipulation. A protocol reads a price from the spot state of an AMM pool, the current reserve ratio. An attacker flash borrows, swaps a huge amount through the pool to distort it, interacts with the victim protocol while its oracle reports the distorted price, swaps back, and repays. The 2020 bZx incidents were early versions; Harvest Finance lost roughly $24M the same year to a stablecoin pool variant. The lesson protocols drew is blunt: never use spot reserves as a price. Time-weighted averages, Chainlink feeds, or any source an attacker cannot move and restore within one transaction.

The second shape is governance. In April 2022, Beanstalk was drained of about $182M by an attacker who flash borrowed enough tokens to hold a supermajority of voting power for one transaction, passed a proposal transferring the treasury to themselves, and repaid the loan. The vulnerability was not the loan; it was counting voting power in the same block the vote executes. Governance systems now snapshot voting power at a past block and enforce execution delays, making transient borrowed voting weight worthless.

Who actually adopted EIP-3156

The honest answer: the standard won the flash-mint niche and lost the big lending pools. MakerDAO’s DssFlash module (MIP-25) implements EIP-3156 to flash mint DAI out of thin air, currently with a zero fee. Aave’s GHO stablecoin does the same: the GhoFlashMinter is built directly on the EIP-3156 reference implementation. OpenZeppelin ships an ERC20FlashMint extension, so any token inheriting it becomes its own EIP-3156 lender, a pattern WETH10 pioneered. If you write a borrower against IERC3156FlashBorrower today, this is the liquidity it speaks to natively.

The largest flash loan venues, meanwhile, kept their native interfaces. Aave V3’s pool still calls executeOperation on an IFlashLoanSimpleReceiver, with a 0.05% fee. Balancer’s vault calls receiveFlashLoan on an IFlashLoanRecipient. Uniswap V2 and V3 keep their flash swap callbacks, uniswapV2Call and uniswapV3FlashCallback, where the fee is the pool’s swap fee. In practice, serious users write a thin adapter layer: one internal strategy function with lender-specific entry callbacks, choosing the venue per trade by depth and fee. EIP-3156 did not unify the market, but it gave that adapter layer a sane default target and standardized the vocabulary every one of these interfaces is now explained in.

Where flash loans meet arbitrage

Arbitrage is the use case that made flash loans famous, because it is the one where the borrowed capital and the repayment are the same asset. Detection is a graph problem: model tokens as vertices, pools as edges weighted by negative log exchange rates, and profitable loops appear as negative cycles, which we walked through for three-token cycles and in the general Bellman-Ford formulation. A cycle whose rate product exceeds 1 by more than the swap fees, the flash fee, and gas is executable profit, and the flash loan sizes that execution by pool depth, not your balance sheet. Execution is where the two primitives snap together: an executor contract takes the loan in onFlashLoan, walks the cycle’s swaps, and lets the lender pull repayment, so an unprofitable cycle reverts instead of losing money. The end-to-end pipeline around that contract is covered in the arbitrage bot tutorial, and the flash loan arbitrage guide walks through wiring the borrowed capital into a full executor contract and scanner bot.

What the contract cannot solve is time. An arbitrage cycle is only real against current state, and it decays the moment anyone else sees it. Before sending, you simulate the whole bundle, borrow through repay, against the latest block, and every simulation is a round of RPC calls. Do that over the public internet at 50 to 500ms per round trip and the opportunity is gone before your bundle is signed. This is why we built BLAZED.sh around co-location: your container or script runs on the same host as a fully synced Ethereum mainnet node, talking to it over a local socket at sub-10ms round trips, with an unfiltered view of the mempool the node itself sees. The strategy in this post is a contract; making it land is an infrastructure problem, and the docs cover deploying next to the node in a few minutes.

FAQ

What is the EIP-3156 magic return value?

keccak256("ERC3156FlashBorrower.onFlashLoan"), returned by the borrower’s callback and verified by the lender before pulling repayment. It proves the receiver deliberately implements the standard, so contracts with permissive fallbacks cannot be dragged into loans without consent.

How much does a flash loan cost?

Whatever flashFee says, plus gas. DAI flash mints currently charge zero, GHO charges a governance-set fee, Aave V3 charges 0.05%, and Uniswap flash swaps cost the pool’s swap fee. The dominant cost of a failed attempt is gas, since a revert returns the principal automatically.

Conclusion

EIP-3156 is a small spec doing a precise job: one lender interface, one borrower callback, a consent proof in the return value, and pull-based repayment that makes the atomicity guarantee explicit. Adoption split along a clean line, standard interfaces for flash mints, native ones for the big pools, but the standard is worth learning first because every other flash loan API is a variation on this flow. Once the contract side is written, the differentiator is not Solidity anymore; it is how fast you can see state, simulate against it, and land the transaction that uses it.