Front-running and MEV
Every pending transaction is visible before it is mined. Anyone can read yours, submit their own with a higher fee, and have it execute first. For a swap, that means being sandwiched: a buy before yours pushes the price up, and a sell after yours captures the difference.
Vulnerable code
function swap(uint256 amountIn) external {
uint256 out = _calculateOut(amountIn);
_executeSwap(amountIn, out); // no minimum, no deadline
}
function claimReward(bytes32 answer) external {
require(answer == secretAnswer, "wrong");
_payReward(msg.sender); // answer visible in mempool
}
Why it breaks
The swap accepts any output amount, so a sandwich can extract an unbounded share of the trade. The reward function is worse: the correct answer is in your transaction's calldata, sitting in the public mempool. A bot reads it, submits the same answer with a higher gas price, and collects the reward before your transaction is mined.
Watch the attack run
The same bug as a stepped sequence. Play it, or walk through with the arrow keys.
The fix
function swap(
uint256 amountIn,
uint256 minOut,
uint256 deadline
) external {
require(block.timestamp <= deadline, "expired");
uint256 out = _calculateOut(amountIn);
require(out >= minOut, "slippage");
_executeSwap(amountIn, out);
}
// Commit-reveal for anything secret:
function commit(bytes32 hash) external {
commits[msg.sender] = Commit(hash, block.number);
}
function reveal(bytes32 answer, bytes32 salt) external {
Commit memory c = commits[msg.sender];
require(block.number > c.blockNumber, "same block");
require(keccak256(abi.encodePacked(answer, salt, msg.sender)) == c.hash);
_payReward(msg.sender);
}
The invariant
The fix above is what an audit gives you. This is what monitoring gives you: the thing that should always be true once the contract is live, stated precisely enough to check every block.
Executed price sits inside the slippage bound that was quoted when the transaction was signed.
invariant executed_out >= quoted_out * (1 - max_slippage) watch same-block opposing swaps from one address
How to test for it
A finding you cannot reproduce is an opinion. Write the test before you write the fix, watch it fail, then make it pass.
function testSandwichBoundedBySlippage() public {
// attacker front-runs
vm.prank(mev); dex.swap(500 ether, 0, block.timestamp);
// victim's trade must revert rather than execute at a bad price
vm.prank(victim);
vm.expectRevert("slippage");
dex.swap(1 ether, expectedOut * 99 / 100, block.timestamp);
}
Where it has caused real losses
Sandwich attacks extract value from ordinary users continuously rather than in single dramatic incidents. The cumulative total across DeFi runs to hundreds of millions of dollars, and unlike most vulnerability classes it requires no bug at all, only a transaction that fails to bound its own outcome.
What we check during review
Front-running cannot be eliminated on a public chain, only bounded. Practical mitigations: always require a user-supplied minOut and deadline rather than computing them on-chain; use commit-reveal for auctions, games and anything with a secret; and consider private transaction relays for high-value operations. Beware the anti-pattern of passing block.timestamp as the deadline. It makes the parameter meaningless.
Want us to check your contract for this?
A free monitoring assessment covers one contract up to 200 lines, manually reviewed, findings back within 72 hours. This class is on the checklist for every review we run.
Related vulnerabilities
Found this useful?
We publish these because the alternative, asking you to trust us, is worth less. Send a contract and we’ll apply the same thinking to your code.
- Reply written by the auditor who read your code
- No sales sequence, no drip campaign, no retargeting
- We’ll tell you if you don’t need a paid audit yet
- Report published only with your written permission