Every time you send a transaction to a public blockchain like Ethereum, whether from a standard wallet or using ERC-4337 smart accounts, it goes to a waiting room. This room is the mempool. It is completely public. Anyone can look inside, see what you are trying to do, and react before your transaction actually executes.
This visibility creates a playground for specialized bots. These bots search for pending transactions they can exploit for profit. We call this process Maximal Extractable Value, or MEV.
The most common and painful exploit for average users is the sandwich attack. If you build smart contracts or run a decentralized exchange (DEX), understanding how these attacks work and how to write code that prevents them is basic security hygiene, similar to avoiding common pitfalls with smart contract allowances.
Anatomy of a Sandwich
A sandwich attack is simple in theory. An attacker finds a victim's pending trade in the mempool, places a transaction right before it, and places another transaction right after it. The victim is sandwiched in the middle.
To make this happen, the attacker uses three distinct steps:
- The Frontrun: The bot sees your pending order to buy an asset. It immediately submits its own buy order for the same asset. The bot pays a higher gas fee (priority fee) or uses a private bundle to ensure the validator processes its transaction first. This purchase pushes the price of the asset up.
- The Victim Trade: Your transaction executes next. Because the bot just pushed the price up, you buy the asset at a much worse price than you expected. You receive fewer tokens than you would have if you went first.
- The Backrun: Immediately after your trade executes, the bot sells the tokens it bought in step one. Because your trade pushed the price up even further, the bot sells at a premium. The bot pockets the difference as pure profit, minus the gas fees.
This entire sequence happens within a single block. The bot takes no inventory risk. It only executes the trade if the math guarantees a profit.
The Math: How Bots Calculate the Profit
To see how this works in practice, let us look at the math behind a constant product market maker, like Uniswap V2. These pools use the formula x * y = k, where x and y represent the token balances in the pool, and k is a constant value that must remain unchanged during swaps.
Imagine a liquidity pool containing 100 ETH and 200,000 USDC. The constant k is 20,000,000. The spot price of 1 ETH is 2,000 USDC.
A user wants to swap 20,000 USDC for ETH.
If no one interferes, the transaction proceeds like this:
- The pool receives 20,000 USDC. The new USDC balance is 220,000.
- The new ETH balance must be
20,000,000 / 220,000, which equals90.909ETH. - The user receives
100 - 90.909 = 9.091ETH. - The user pays an average price of roughly 2,200 USDC per ETH.
Now, an MEV bot spots this transaction in the mempool. The bot decides to frontrun the trade by swapping 10,000 USDC for ETH first.
Here is the new sequence:
Step 1: The Frontrun
- The pool receives the bot's 10,000 USDC. The new USDC balance is 210,000.
- The new ETH balance is
20,000,000 / 210,000 = 95.238ETH. - The bot receives
100 - 95.238 = 4.762ETH.
Step 2: The Victim Swap
- The pool receives the user's 20,000 USDC. The USDC balance goes from 210,000 to 230,000.
- The new ETH balance is
20,000,000 / 230,000 = 86.956ETH. - The user receives
95.238 - 86.956 = 8.282ETH. - Because of the bot, the user lost
0.809ETH (worth about 1,780 USDC at the new price).
Step 3: The Backrun
- The bot immediately swaps its
4.762ETH back for USDC. - The pool's ETH balance goes from 86.956 back to
86.956 + 4.762 = 91.718ETH. - The new USDC balance is
20,000,000 / 91.718 = 218,060USDC. - The pool pays out USDC to the bot. The pool's USDC balance went from 230,000 to 218,060. The bot receives
230,000 - 218,060 = 11,940USDC.
The bot started with 10,000 USDC and ended with 11,940 USDC. It made a gross profit of 1,940 USDC in a single block. The victim absorbed this entire loss in the form of slippage.
Writing Vulnerable Smart Contracts
Sandwich attacks happen because smart contracts allow too much slippage. When developers write contracts that interact with DEXs, they often take shortcuts.
Here is an example of a vulnerable Solidity function using the Uniswap V2 Router:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IUniswapV2Router {
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external returns (uint[] memory amounts);
}
contract VulnerableSwapper {
IUniswapV2Router public immutable router;
constructor(address _router) {
router = IUniswapV2Router(_router);
}
// This function is highly vulnerable to sandwich attacks
defswap(
address tokenIn,
address tokenOut,
uint amountIn
) external {
SafeERC20.safeTransferFrom(IERC20(tokenIn), msg.sender, address(this), amountIn);
IERC20(tokenIn).approve(address(router), amountIn);
address[] memory path = new address[](2);
path[0] = tokenIn;
path[1] = tokenOut;
// CRITICAL BUG: Setting amountOutMin to 0
router.swapExactTokensForTokens(
amountIn,
0,
path,
msg.sender,
block.timestamp
);
}
}The bug in this code is the second parameter in the swapExactTokensForTokens call: 0.
By setting amountOutMin to zero, the contract tells the pool: "I do not care how many tokens I get back. Even if I get 1 wei of tokenOut, execute the transaction."
MEV bots look for this parameter. If they see amountOutMin is zero, they can execute a massive frontrun trade, push the price to the absolute limit, and leave the victim with next to nothing.
How to Protect Your Smart Contracts
To defend your protocols and your users, you must implement strict slippage controls and price verification.
1. Pass Slippage Parameters from Off-Chain
The most effective defense is to calculate the expected output off-chain and pass a strict amountOutMin parameter directly from the frontend or user interface.
The user's wallet calculates the current pool price, applies a reasonable slippage tolerance (like 0.5% or 1%), and signs the transaction with that specific limit.
Here is how you update the contract to accept this parameter:
contract SecureSwapper {
IUniswapV2Router public immutable router;
constructor(address _router) {
router = IUniswapV2Router(_router);
}
// The user passes the minimum acceptable output calculated off-chain
function safeSwap(
address tokenIn,
address tokenOut,
uint amountIn,
uint amountOutMin
) external {
SafeERC20.safeTransferFrom(IERC20(tokenIn), msg.sender, address(this), amountIn);
IERC20(tokenIn).approve(address(router), amountIn);
address[] memory path = new address[](2);
path[0] = tokenIn;
path[1] = tokenOut;
router.swapExactTokensForTokens(
amountIn,
amountOutMin, // Enforced minimum output
path,
msg.sender,
block.timestamp
);
}
}If an MEV bot tries to sandwich this transaction, the price impact of the frontrun will push the output below amountOutMin. The transaction will revert, the bot will waste gas, and the user's funds remain safe.
2. Use On-Chain Oracles for Price Verification
If your smart contract must execute swaps automatically without user input (for example, a vault performing yield optimization or compounding rewards), you cannot rely on a user to pass an amountOutMin value. You must calculate it on-chain.
Never use the spot price of the pool you are swapping on to calculate slippage. A bot can easily manipulate the spot price in a single transaction before your contract reads it.
Instead, use a decentralized oracle like Chainlink or a Time-Weighted Average Price (TWAP) from Uniswap V3.
Here is an example using a Chainlink Price Feed to verify the minimum output:
interface IChainlinkAggregator {
function latestRoundData()
external
view
returns (
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
);
}
contract OracleProtectedSwapper {
IUniswapV2Router public immutable router;
IChainlinkAggregator public immutable priceFeed; // ETH/USDC feed
constructor(address _router, address _priceFeed) {
router = IUniswapV2Router(_router);
priceFeed = IChainlinkAggregator(_priceFeed);
}
function swapWithOracleLimit(
uint amountInETH,
uint maxSlippageBps // e.g., 100 bps = 1%
) external {
// 1. Get the reference price from Chainlink
(, int256 price, , uint256 updatedAt, ) = priceFeed.latestRoundData();
require(price > 0, "Invalid oracle price");
require(block.timestamp - updatedAt < 3600, "Stale price feed");
// 2. Calculate the expected output in USDC
uint expectedUSDC = (amountInETH * uint(price)) / 1e8;
// 3. Apply slippage tolerance (e.g., 1% slippage)
uint amountOutMin = (expectedUSDC * (10000 - maxSlippageBps)) / 10000;
// Approve and swap
SafeERC20.safeTransferFrom(IERC20(WETH), msg.sender, address(this), amountInETH);
IERC20(WETH).approve(address(router), amountInETH);
address[] memory path = new address[](2);
path[0] = WETH;
path[1] = USDC;
router.swapExactTokensForTokens(
amountInETH,
amountOutMin, // Calculated safely using oracle price
path,
msg.sender,
block.timestamp
);
}
}By reading the price from an independent source, the contract knows the true market rate. If the spot price in the Uniswap pool deviates significantly from the oracle price due to a frontrun attempt, the transaction reverts.
3. Route Transactions Through Private Mempools
For users and searchers who want to avoid the public mempool entirely, private transaction routing is the industry standard.
Services like Flashbots Protect, MEV-Share, and Blocknative allow users to send transactions directly to block builders. These transactions bypass the public mempool.
Because the transaction is invisible to public searchers, MEV bots cannot see it to construct a sandwich. The block builder includes the transaction directly in a block. If the transaction would revert or get frontrun, the builder simply drops it, saving the user from paying gas fees on a failed trade.
Developers can integrate private RPC endpoints directly into their dapp frontends. When a user connects their wallet, the dapp routes the transaction through a secure RPC like https://rpc.flashbots.net instead of the default network provider.
The Cost of Protection
Implementing these defenses is not free.
Calculating Uniswap V3 TWAP values on-chain requires historical observation storage, which increases deployment and execution gas costs. Reading from Chainlink feeds adds external call overhead to your functions.
But these costs are negligible compared to the capital lost to MEV bots. A single unhedged swap of a large treasury can lose tens of thousands of dollars to a sandwich bot in seconds.
As a smart contract developer, you should treat slippage protection as a non-negotiable requirement. Never write amountOutMin = 0 in production code. Always validate prices against an external reference, and give your users the tools to protect their transactions from the public mempool.



