3.8.7 Front-running & MEV Exposure

Every transaction submitted to Ethereum's public mempool is visible before it is mined. Block builders — the entities that order transactions into blocks — can read the pending transactions, evaluate which ones would be profitable to reorder, and construct blocks that extract value from that reordering. The extracted value is maximal extractable value (MEV), and the contracts that lose it to the extractors are said to be MEV-exposed.

Most MEV is not a "vulnerability" in the classical sense. Arbitrage between two DEXes is MEV; liquidation of an unhealthy lending position is MEV; both are legitimate activities the protocol explicitly invites. The vulnerabilities arise when a contract assumes things about transaction ordering that are not true — that the user's transaction will execute against the state they observed when they signed it, that no other transaction will land between two of their actions, that the price they computed off-chain will be the price they pay on-chain.

This section covers the specific function-level patterns where front-running and MEV produce direct user losses. Section 3.11.3 covers architectural MEV mitigation: private mempools, batch auctions, threshold encryption, MEV-Boost dynamics. This section is about the bugs in individual functions that make ordinary protocols MEV-extractive without anyone designing them to be.

The losses are pervasive but distributed. Sandwich attacks on Uniswap V2/V3 transactions extract an estimated $1 billion+ per year from retail users. JIT (just-in-time) liquidity manipulation extracts unknown amounts from concentrated-liquidity providers. NFT mint front-running, governance proposal sandwiching, oracle update front-running, and approval-race attacks all add up. The aggregate cost is enormous; the per-user cost is often small enough that users don't notice.

Classic Front-running: The Race for State

The simplest case. A function's outcome depends on contract state that any pending transaction could change. An attacker observes a pending profitable transaction, submits the same transaction first with higher gas, and reaps the profit. The victim's transaction either fails or executes against the post-attack state.

Vulnerable Example

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

contract BugBounty {
    bytes32 public answerHash;
    uint256 public reward;

    constructor(bytes32 _answerHash) payable {
        answerHash = _answerHash;
        reward = msg.value;
    }

    // BUG: visible in mempool, front-runnable
    function claim(string calldata answer) external {
        require(keccak256(abi.encodePacked(answer)) == answerHash, "wrong answer");
        uint256 payout = reward;
        reward = 0;
        payable(msg.sender).transfer(payout);
    }
}

The flow:

  1. Researcher discovers the answer "the_secret_passphrase"
  2. Researcher submits claim("the_secret_passphrase") with normal gas
  3. The transaction sits in the mempool, visible to everyone
  4. MEV searcher reads the transaction, sees the literal answer in the calldata, submits the same call with higher gas
  5. Searcher's transaction lands first; researcher's transaction reverts (already claimed)
  6. Searcher collected the bounty; researcher did the work

The plaintext answer is visible in calldata before the transaction is mined. This is not a sophisticated attack — any mempool watcher script can detect and front-run this pattern.

Fixed Example: Commit-Reveal

The standard defense is the commit-reveal pattern (Section 3.7.4). The user first commits a hash that hides the answer; later, after the commit is final, they reveal:

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

contract BugBounty {
    bytes32 public answerHash;
    uint256 public reward;

    mapping(bytes32 => uint256) public commitTime;
    mapping(bytes32 => address) public committer;
    uint256 public constant REVEAL_DELAY = 5 minutes;

    constructor(bytes32 _answerHash) payable {
        answerHash = _answerHash;
        reward = msg.value;
    }

    function commit(bytes32 commitment) external {
        // commitment = keccak256(abi.encodePacked(answer, salt, msg.sender))
        require(commitTime[commitment] == 0, "already committed");
        commitTime[commitment] = block.timestamp;
        committer[commitment] = msg.sender;
    }

    function reveal(string calldata answer, bytes32 salt) external {
        bytes32 commitment = keccak256(abi.encodePacked(answer, salt, msg.sender));
        uint256 cTime = commitTime[commitment];
        require(cTime != 0, "no commit");
        require(committer[commitment] == msg.sender, "not your commit");
        require(block.timestamp >= cTime + REVEAL_DELAY, "reveal too soon");

        require(keccak256(abi.encodePacked(answer)) == answerHash, "wrong answer");

        uint256 payout = reward;
        reward = 0;
        delete commitTime[commitment];
        payable(msg.sender).transfer(payout);
    }
}

The key elements:

  • The commitment hash includes msg.sender. Without this, a searcher who sees the reveal could replay the commit from their own address and claim. Binding the commit to the originator makes the commit only redeemable by that address.
  • The salt prevents brute-forcing. A commitment hash of just (answer) could be reverse-engineered by a searcher who already knows the answer from the public reveal. The salt makes the search space infeasibly large.
  • The reveal delay ensures finality. A searcher who sees both the commit and the reveal in the same block could still front-run the reveal. The delay forces the commit to land in an earlier block before the reveal becomes valid.

Trade-off

Commit-reveal turns a one-transaction operation into two transactions, increasing UX friction and gas cost. For high-value or competitive operations, it's the right trade. For low-value or non-competitive operations, the protection isn't needed.

Cross-reference: Section 3.7.4 covers commit-reveal as a pattern; this section shows it applied as a vulnerability fix.

Sandwich Attacks on AMM Swaps

The most common MEV pattern in DeFi. A user's swap moves the price; an attacker buys before the user (pushing price up against the user's direction), lets the user execute (worse price than expected), then sells after the user (taking profit from the price impact the user just caused).

Vulnerable Pattern

// User-level: a naive swap interface
contract NaiveSwapInterface {
    function swap(address tokenIn, address tokenOut, uint256 amountIn) external returns (uint256 amountOut) {
        // BUG: no slippage protection
        amountOut = uniswapRouter.swapExactTokensForTokens(
            amountIn,
            0,  // accept any amount of output tokens
            _pathFor(tokenIn, tokenOut),
            msg.sender,
            block.timestamp
        );
    }
}

The 0 minimum-output parameter is the key bug. The user is saying "I'll accept any amount of output tokens." An attacker sandwiches:

  1. Front-run: Attacker swaps a large amount of tokenIn → tokenOut, pushing tokenOut's price up
  2. Victim's transaction: User receives far less tokenOut than they would have (the price is now bad), but accepts it anyway because min = 0
  3. Back-run: Attacker swaps the tokenOut they accumulated back to tokenIn at the inflated price, profiting from the user's price impact

The user paid the attacker through slippage. The attacker risks nothing — both legs of the sandwich are atomic within the same block.

Fixed Example: Slippage Protection

function swap(
    address tokenIn,
    address tokenOut,
    uint256 amountIn,
    uint256 minAmountOut,
    uint256 deadline
) external returns (uint256 amountOut) {
    require(block.timestamp <= deadline, "expired");

    amountOut = uniswapRouter.swapExactTokensForTokens(
        amountIn,
        minAmountOut,
        _pathFor(tokenIn, tokenOut),
        msg.sender,
        deadline
    );
}

Three defenses applied:

  1. minAmountOut caps the slippage tolerance. If the on-chain price has moved enough that the user would receive less than minAmountOut, the swap reverts. The user controls how much slippage they accept.

  2. deadline caps how long the signed transaction is valid. A transaction held in the mempool for hours could be executed during an unrelated price move; the deadline prevents stale execution.

  3. The price calculation happens in the user's wallet. The user (or their dApp) reads the current price via eth_call, computes the expected output, applies a slippage tolerance, and signs a transaction with that minAmountOut. The signed transaction commits to the user's price tolerance, not to a specific price.

Choosing Slippage Tolerance

The slippage parameter is the user's choice but the protocol should default it sensibly. Common patterns:

  • Stable-pair swaps (USDC ↔ DAI): 0.1% slippage is generous; 0.05% is reasonable
  • Major-asset swaps (WETH ↔ USDC): 0.5% is typical; 1% covers most normal volatility
  • Volatile asset swaps: 1-3% depending on the asset
  • Large swaps relative to pool size: higher slippage needed to absorb the user's own price impact

Setting slippage too low causes legitimate transactions to revert during normal volatility. Setting too high invites sandwich attacks. The Uniswap UI defaults to 0.5% with user override; 1Inch's auto-slippage adjusts based on token volatility and trade size.

The Slippage Bypass: Setting Tolerance to 100%

Some dApps allow users to disable slippage protection entirely — sometimes called "MEV-tolerant" or "force execute." When a user is desperate to get a transaction through during high volatility, they may set slippage to a very high value. This is exactly when sandwich attacks are most profitable.

The correct UX response is to refuse to submit transactions with very high slippage to the public mempool. Some interfaces (CowSwap, MEV-Blocker) route high-slippage transactions through private orderflow channels where searchers cannot sandwich them. This is architectural MEV mitigation; Section 3.11.3 covers it in depth.

Liquidation Front-running

When an under-collateralized position becomes liquidatable, multiple liquidators race to be the one who collects the liquidation bonus. The race is competitive but not in itself a vulnerability — protocols design liquidation incentives to ensure positions get liquidated quickly.

The vulnerability arises in adjacent design choices: a position that's about to become liquidatable may be exposed to oracle update front-running. An attacker watches the oracle's next update, knows it will push a position into liquidation territory, and submits a liquidation that lands immediately after the oracle update.

Vulnerable Pattern

function liquidate(address borrower) external {
    uint256 price = oracle.getPrice();
    uint256 collateralValue = collateral[borrower] * price / 1e18;
    require(collateralValue < debt[borrower] * LIQUIDATION_THRESHOLD / 100, "healthy");

    // Liquidator pays off debt, receives collateral at discount
    uint256 collateralReceived = collateral[borrower];
    uint256 debtRepaid = debt[borrower];

    collateral[borrower] = 0;
    debt[borrower] = 0;

    IERC20(debtToken).transferFrom(msg.sender, address(this), debtRepaid);
    IERC20(collateralToken).transfer(msg.sender, collateralReceived);
}

This function is correct in isolation — the price check is fresh, the bookkeeping is consistent. The vulnerability is at the protocol level: the oracle updates atomically with the liquidation, allowing whoever wins the gas race to capture the entire liquidation discount before the borrower has any chance to react.

For the borrower, this is the worst-case liquidation: they lose collateral at maximum penalty in the moment of greatest market stress. There's no opportunity to repay or add collateral first.

Mitigations

The borrower-friendly defenses involve:

  1. Liquidation grace periods. A position becomes liquidatable, but liquidation requires the position to remain liquidatable for some duration (e.g., one block, 30 seconds). This gives the borrower time to react.
mapping(address => uint256) public liquidatableSince;

function markLiquidatable(address borrower) external {
    uint256 price = oracle.getPrice();
    uint256 collateralValue = collateral[borrower] * price / 1e18;
    require(collateralValue < debt[borrower] * LIQUIDATION_THRESHOLD / 100, "healthy");
    require(liquidatableSince[borrower] == 0, "already marked");
    liquidatableSince[borrower] = block.timestamp;
}

function liquidate(address borrower) external {
    require(liquidatableSince[borrower] > 0, "not marked");
    require(block.timestamp >= liquidatableSince[borrower] + GRACE_PERIOD, "grace period");
    // ... liquidation logic
}

function cure(address borrower) external {
    uint256 price = oracle.getPrice();
    uint256 collateralValue = collateral[borrower] * price / 1e18;
    require(collateralValue >= debt[borrower] * LIQUIDATION_THRESHOLD / 100, "still unhealthy");
    liquidatableSince[borrower] = 0;  // un-mark
}
  1. Partial liquidation only. Limit each liquidation to a fraction (e.g., 50%) of the position. The borrower can recover the remaining position; the liquidator gets a smaller bonus, but the borrower isn't wiped out in one transaction.

  2. Dutch auction liquidations. The liquidation discount grows over time rather than being fixed. Liquidators wait for the discount to be worth the gas cost; faster liquidators capture smaller discounts; the system tends toward efficient pricing rather than gas wars. Maker DAO's auction system works this way.

The trade-off across all three is the same: borrower protection vs. protocol risk. A protocol with too lenient liquidation may accumulate bad debt during fast moves. A protocol with too aggressive liquidation extracts excess value from borrowers. There is no perfect setting.

Approval Race / Allowance Front-running

A specific pattern that affects ERC-20 approvals. The well-known case: changing an existing approval from N to M (where both are nonzero) creates a window where an attacker can use the old approval and the new approval.

Vulnerable Pattern

// User flow:
// 1. User approved 100 USDC to Spender at some prior time
// 2. User wants to change approval to 50 USDC
// 3. User calls token.approve(spender, 50)

// Attacker flow:
// 1. Attacker monitors the mempool, sees the approve(spender, 50) transaction
// 2. Attacker submits transferFrom(user, attacker, 100) with higher gas
// 3. Attacker's transferFrom executes first, consuming the old 100 approval
// 4. User's approve(spender, 50) executes, setting the new approval
// 5. Attacker calls transferFrom(user, attacker, 50), consuming the new approval
// 6. Total stolen: 150 USDC instead of the intended 100 maximum

This is the canonical "approval race" attack, originally documented in 2016. The ERC-20 specification doesn't prevent it; the responsibility falls on user wallets and dApp interfaces.

Defenses

Defense 1: Set approval to zero first, then to the new value.

token.approve(spender, 0);     // first transaction
token.approve(spender, 50);    // second transaction

The two-step pattern eliminates the race window. If the attacker tries to use the old approval between the two transactions, they can extract at most the old approval; the new transaction creates a fresh, independent approval rather than overwriting one that the attacker has consumed.

Defense 2: Use increaseAllowance / decreaseAllowance.

These functions modify the existing allowance by a delta rather than overwriting it:

import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";

// OpenZeppelin's ERC20 has these built in
token.increaseAllowance(spender, 50);  // adds 50 to existing allowance
token.decreaseAllowance(spender, 25);  // subtracts 25; reverts if would go below 0

The delta-based functions are race-safe because they don't create a window where the old value can be exploited. Note that as of OpenZeppelin v5, increaseAllowance and decreaseAllowance were removed in favor of approve alone, on the rationale that ERC-20 wallets and dApps have universally adopted set-to-zero-first patterns. If your token uses OZ v5, the workaround is back to the two-step set-to-zero pattern.

Defense 3: Use Permit (EIP-2612) instead of approve.

Permit signs an approval off-chain that's consumed atomically with the spending transaction. There's no window between approval and spend because both happen in the same transaction:

token.permit(user, spender, amount, deadline, v, r, s);
token.transferFrom(user, recipient, amount);

Section 3.7.4 covers Permit in depth. For tokens that support it, Permit eliminates the approval race entirely. For tokens that don't (most older ERC-20s), the set-to-zero workaround remains the fallback.

NFT Mint Front-running

Public NFT mints are inherently race conditions when supply is limited. Bots monitor the mint contract and submit mint transactions with high gas the moment the mint goes live. Regular users with normal gas settings get sniped.

The Mint Storm

contract NaiveMint {
    uint256 public totalSupply;
    uint256 public constant MAX_SUPPLY = 10000;
    uint256 public constant PRICE = 0.1 ether;

    function mint(uint256 quantity) external payable {
        require(msg.value == quantity * PRICE);
        require(totalSupply + quantity <= MAX_SUPPLY);
        for (uint256 i = 0; i < quantity; ++i) {
            _mintTo(msg.sender);
        }
        totalSupply += quantity;
    }
}

When this mint opens, bots will:

  1. Detect the contract's existence through deployment monitoring
  2. Submit mint transactions in the same block as the mint going live, often with priority-fee escalation
  3. Mint as many as the per-transaction limit allows (which here is unlimited — another bug)

A normal user submitting a single mint with standard gas often loses to the bot competition. The collection sells out in seconds, with concentrated ownership by a handful of bot operators.

Defenses

Per-address mint cap.

mapping(address => uint256) public minted;
uint256 public constant PER_ADDRESS_LIMIT = 5;

function mint(uint256 quantity) external payable {
    require(minted[msg.sender] + quantity <= PER_ADDRESS_LIMIT, "exceeds per-address limit");
    require(msg.value == quantity * PRICE);
    require(totalSupply + quantity <= MAX_SUPPLY);
    minted[msg.sender] += quantity;
    for (uint256 i = 0; i < quantity; ++i) {
        _mintTo(msg.sender);
    }
    totalSupply += quantity;
}

A per-address cap doesn't prevent bots (they can use many addresses) but it raises their cost — they need to fund and execute from many addresses, increasing total gas cost and operational complexity.

Allowlist with off-chain enrollment.

Allow only addresses that registered ahead of time (via signature, Merkle proof, or on-chain enrollment in an earlier phase). The competitive race becomes one for the enrollment slots rather than the mint slots; this can be designed to favor legitimate users (CAPTCHAs off-chain, KYC if applicable, or just earlier announcement of allowlist requirements).

Dutch auction pricing.

Start the mint price high and decrease over time. Bots that want to mint pay a high initial price; users who can wait pay less. The race becomes one of patience vs. eagerness, which is a better economic alignment than the current race for gas price.

function currentPrice() public view returns (uint256) {
    uint256 elapsed = block.timestamp - mintStart;
    if (elapsed >= AUCTION_DURATION) return FLOOR_PRICE;
    return START_PRICE - (START_PRICE - FLOOR_PRICE) * elapsed / AUCTION_DURATION;
}

Commit-reveal mint.

For especially valuable mints (PFP projects, gaming items), use commit-reveal to randomize which user receives which NFT. The mint still races, but the reveal phase prevents bots from cherry-picking specific token IDs:

function commitMint(bytes32 commitment) external payable {
    require(msg.value == PRICE);
    commitments[msg.sender] = commitment;
    commitTime[msg.sender] = block.timestamp;
}

function reveal(uint256 seed, bytes32 salt) external {
    require(block.timestamp >= commitTime[msg.sender] + REVEAL_DELAY);
    require(keccak256(abi.encodePacked(seed, salt, msg.sender)) == commitments[msg.sender]);
    uint256 tokenId = _assignTokenFromSeed(seed);
    _mint(msg.sender, tokenId);
}

When Front-running is Acceptable

Not every MEV exposure is a vulnerability. Two categories where MEV is the intended behavior:

Arbitrage. A price difference between two DEXes is a market inefficiency that MEV searchers correct. The protocols benefit from accurate prices; the searchers earn a fee. The arbitrage is socially valuable and the contracts are designed to permit it.

Liquidation. An unhealthy position is a risk to the protocol. Liquidators race to liquidate; the winner takes the discount. The protocol benefits from rapid liquidation; the searchers compete. The protocol is designed to invite this MEV.

The vulnerability case is when a user thought they were getting a fair price but actually paid a sandwich tax, or when a user thought their bug bounty submission would be private but lost it to a front-runner. The fix is to design functions so that "MEV exposure" matches "MEV the protocol invites."

Quick Reference

PatternWhat goes wrongDefense
Plaintext answer in calldataAnyone can read the answer from the mempool and submit it firstCommit-reveal with sender binding and reveal delay
Sandwich attack on AMM swapAttacker reorders around victim, extracting price impactminAmountOut slippage parameter; sensible defaults; private orderflow for high-slippage cases
Liquidation front-runningOracle update + atomic liquidation captures bonus before borrower can reactGrace periods, partial liquidation, Dutch auction liquidations
Approval raceOld approval still active while user transitions to new valueSet to zero first; increaseAllowance/decreaseAllowance; Permit (EIP-2612)
NFT mint snipingBots dominate competitive mints with high gasPer-address caps; allowlists; Dutch auction; commit-reveal

Cross-References

  • Commit-Reveal pattern — Section 3.7.4 covers the pattern in depth
  • Permit (EIP-2612) — Section 3.7.4 also covers permit; Section 3.8.8 covers signature mechanics
  • Oracle manipulation — Section 3.8.5 covers the related case where the price source itself is manipulated
  • MEV architectural mitigation — Section 3.11.3 covers private mempools, batch auctions, and threshold encryption
  • Flash loans — Section 3.11.4 covers flash loans, the capital primitive enabling many MEV strategies
  • Real exploits — Section 3.10 includes incidents where front-running was the attack vector
  • Auditor's view — Section 4.13 (Front-running Vectors) covers detection during audit