MEV (maximal extractable value) is the profit available to whoever decides the order of transactions in a block. The best-known harmful kind is the sandwich attack. A searcher spots your pending swap, buys the same asset just before you to push the price up, lets your swap fill at the worse price, then sells right after you. Their profit comes out of your execution, and it is capped by your slippage tolerance. You protect users by keeping that cap tight and by keeping swaps out of the public mempool.
Where MEV comes from
On Ethereum, a pending transaction sent to a normal RPC is gossiped through the public mempool, where anyone can read it before it's included. Most blocks are now built through proposer-builder separation via MEV-Boost:
- Searchers scan the mempool and state for opportunities and send bundles (ordered transaction lists) to builders.
- Builders assemble the most profitable block they can from public transactions and bundles.
- Relays pass the best block bids to the proposer (validator), who picks the highest-paying one.
Not all MEV is harmful. Arbitrage lines up prices across pools, and liquidations keep lending markets solvent. Sandwiches are different: they take value directly from a specific user.
Anatomy of a sandwich
Say a user swaps 5 ETH for USDC with 1% slippage. That means "fill me as long as I receive at least 99% of the quoted amount".
Block N:
tx 1 searcher: buy USDC with ETH -> pushes the ETH/USDC price against the user
tx 2 user: swap 5 ETH for USDC -> fills close to the 1% limit
tx 3 searcher: sell USDC for ETH -> captures the price difference
The searcher sizes tx 1 so the user's swap just clears amountOutMinimum. That is why slippage tolerance is effectively the most a sandwich can take from you. At 1% they can take up to about 1% (less pool fees and gas), and with amountOutMinimum = 0 they can take nearly everything. You can often spot a sandwich on a block explorer as two transactions from the same bot, trading the same pool, directly before and after the victim's swap.
Protection 1: a tight, fresh minimum output
Compute the minimum from a fresh off-chain quote just before sending, and set slippage to match the pool's liquidity and the trade size. Don't use a one-size-fits-all 5%.
import { createPublicClient, http, parseAbi, parseUnits } from "viem";
import { mainnet } from "viem/chains";
const QUOTER_V2 = "0x..." as const; // from Uniswap's official deployments list
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" as const;
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" as const;
const publicClient = createPublicClient({ chain: mainnet, transport: http(process.env.RPC_URL) });
const quoterAbi = parseAbi([
"function quoteExactInputSingle((address tokenIn, address tokenOut, uint256 amountIn, uint24 fee, uint160 sqrtPriceLimitX96) params) returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)",
]);
// QuoterV2 isn't a view function, so simulate it with eth_call
const { result } = await publicClient.simulateContract({
address: QUOTER_V2,
abi: quoterAbi,
functionName: "quoteExactInputSingle",
args: [{ tokenIn: WETH, tokenOut: USDC, amountIn: parseUnits("5", 18), fee: 500, sqrtPriceLimitX96: 0n }],
});
const [quotedOut] = result;
const slippageBps = 30n; // 0.30%, the most a sandwich can take
const amountOutMinimum = (quotedOut * (10_000n - slippageBps)) / 10_000n;
Set a real deadline too, so a transaction that sits around for a while can't be filled later at a stale price.
Protection 2: don't broadcast swaps publicly
Send transactions through a private RPC that forwards them to builders without putting them in the public mempool. Searchers can't sandwich what they can't see. Two widely used options on Ethereum mainnet are Flashbots Protect (https://rpc.flashbots.net) and MEV Blocker (https://rpc.mevblocker.io). In a wallet, just add the RPC. In code, point the wallet client at it:
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "viem/chains";
const walletClient = createWalletClient({
account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
chain: mainnet,
transport: http("https://rpc.flashbots.net"),
});
The trade-offs: you trust the RPC operator and the builders it shares with, and inclusion can take a little longer because only participating builders see your transaction. Private submission lowers the risk of a sandwich, but it doesn't replace protection 1.
Protection 3: execution that isn't a public market order
- Batch auctions and intents. CoW Protocol settles orders in batches at a uniform clearing price, and UniswapX has fillers compete to fill signed orders. In both, the user signs an order with a minimum outcome instead of broadcasting a raw swap, and solvers or fillers compete to execute it.
- Limit orders instead of market swaps when timing isn't urgent.
- Split large trades, or route through deeper liquidity. Price impact is what makes a trade worth sandwiching.
If your contract swaps, you're a target too
Vaults, treasuries and keepers that swap on-chain get sandwiched constantly. The common bugs:
// Inside a vault using Uniswap V3's original SwapRouter.
// minOut and deadline are computed off-chain from a fresh quote.
function harvest(uint256 amountIn, uint256 minOut, uint256 deadline) external {
require(msg.sender == keeper, "not keeper");
router.exactInputSingle(ISwapRouter.ExactInputSingleParams({
tokenIn: reward,
tokenOut: want,
fee: 3000,
recipient: address(this),
deadline: deadline, // not block.timestamp, which always passes
amountIn: amountIn,
amountOutMinimum: minOut, // never 0
sqrtPriceLimitX96: 0
}));
}
amountOutMinimum: 0hands the whole trade to anyone who sandwiches it.- Computing the minimum on-chain from the current spot price doesn't help either: the attacker has already moved that price earlier in the same block. Get the bound from the caller, from an off-chain quote, or from a manipulation-resistant oracle (a TWAP or a price feed).
deadline: block.timestampalways passes, so it offers no protection.
On rollups with a single sequencer and no public mempool, classic mempool sandwiching is harder, but the same slippage discipline still applies.
Checklist
- Never ship
amountOutMinimum = 0. Derive it from a fresh quote with slippage suited to the trade. - Use real deadlines, not
block.timestamp. - Default users to a private RPC (Flashbots Protect, MEV Blocker) or intent-based execution.
- In contracts, take minimum outputs from callers or robust oracles, never from spot price.
- Warn users about large trades in thin pools, and offer limit orders or splitting.
Get the weekly commit
New blockchain deep dives every week.