Welcome back to the "Solodit Checklist Explained" series.
Today, we are dissecting the Sandwich Attack, a deliberate process of ordering transactions that targets users on decentralized exchanges (DEXs). This attack leverages the public nature of blockchain mempools to manipulate prices around a victim's trade, leading to financial losses for the user and profit for the attacker. We'll examine the Solodit checklist item SOL-AM-SandwichAttack-1
to understand the vulnerability and implement robust defenses.
This is part of the "Solodit Checklist Explained" series. You can find the previous articles here:
For the best experience, open a tab with the Solodit checklist and refer to it as you read. Complete code examples are available on GitHub.
A sandwich attack is a malicious strategy where an attacker places two transactions around a victim's pending transaction to profit from price changes. This is made possible by the transparency of the mempool—a public waiting area where transactions sit before being confirmed by miners or validators.
The anatomy of a sandwich attack involves three steps:
The victim's trade is "sandwiched" between the attacker's two transactions.
The core vulnerability that enables this is the lack of slippage protection. Slippage refers to the difference between the expected price of a trade and the actual price it was executed. In volatile markets or with large orders, prices can change between the time a transaction is submitted and when it is confirmed.
The mechanism that allows a user to define the maximum acceptable price change for their trade is called slippage protection. It ensures that if the price moves beyond a specified threshold, the transaction will revert instead of executing at an unfavorable price.
Without slippage protection, users are exposed to direct financial loss, and the perceived fairness of the protocol is undermined.
Let's explore how to identify and fix this vulnerability.
Description: An attacker can monitor the mempool and place two transactions before and after the user's transaction. For example, when an attacker spots a large trade, they execute their own trade first to manipulate the price. Then, they profit by closing their position after the user's trade executes.
Remediation: Allow users to specify the minimum output amount and revert the transaction if it is not satisfied.
Checklist item SOL-AM-SandwichAttack-1
targets the most common enabler of sandwich attacks: swap functions that do not allow users to specify their minimum acceptable output.
Consider this SimpleSwap
contract, which implements a basic Automated Market Maker (AMM). It allows users to swap tokenA
for tokenB
but lacks slippage control.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
// This is the vulnerable contract
contract SimpleSwap {
IERC20 public tokenA;
IERC20 public tokenB;
uint256 public reserveA;
uint256 public reserveB;
constructor(IERC20 _tokenA, IERC20 _tokenB, uint256 _reserveA, uint256 _reserveB) {
tokenA = _tokenA;
tokenB = _tokenB;
reserveA = _reserveA;
reserveB = _reserveB;
}
// VULNERABILITY: No minimum output amount specified.
function swapAforB(uint256 amountA) external returns (uint256 amountB)
{
require(amountA > 0, "Amount must be greater than 0");
// Constant product formula with 0.3% fee
uint256 amountInWithFee = amountA * 997;
amountB = (amountInWithFee * reserveB) / (reserveA * 1000 + amountInWithFee);
require(tokenA.transferFrom(msg.sender, address(this), amountA), "Transfer failed");
require(tokenB.transfer(msg.sender, amountB), "Transfer failed");
reserveA += amountA;
reserveB -= amountB;
return amountB;
}
// ... other functions (swapBforA, getAmountOut)
}
The swapAforB
function calculates the output amount (amountB
) based on the current reserves and executes the trade. A user calling this function has no control over the final price. If an attacker manipulates the reserves before their transaction executes, the user will receive fewer tokens than expected and the transaction will still succeed.
This Foundry test demonstrates a complete sandwich attack.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "./SimpleSwap.sol"; // Assume vulnerable contract is in this file
// ... ERC20 and TestToken contracts
contract SandwichAttackTest is Test {
SimpleSwap public swap;
TestToken public tokenA;
TestToken public tokenB;
address attacker = address(1);
address victim = address(2);
function setUp() public {
// 1. Deploy contracts and set initial state
tokenA = new TestToken("TokenA", "TKA");
tokenB = new TestToken("TokenB", "TKB");
// Initial liquidity: 1000 TKA and 1000 TKB
swap = new SimpleSwap(tokenA, tokenB, 1000 ether, 1000 ether);
// 2. Fund the pool and participants
tokenA.mint(address(swap), 1000 ether);
tokenB.mint(address(swap), 1000 ether);
tokenA.mint(attacker, 300 ether);
tokenA.mint(victim, 150 ether);
// 3. Grant approvals for the swap contract
vm.prank(attacker);
tokenA.approve(address(swap), type(uint256).max);
vm.prank(attacker);
tokenB.approve(address(swap), type(uint256).max);
vm.prank(victim);
tokenA.approve(address(swap), type(uint256).max);
}
function testSandwichAttack() public {
uint256 victimAmountA = 100 ether; // Victim wants to swap 100 TKA
uint256 attackerAmountA = 200 ether; // Attacker uses 200 TKA for the attack
// Calculate expected output for the victim in a fair market
uint256 expectedTokenB = swap.getAmountOut(victimAmountA, 1000 ether, 1000 ether);
uint256 attackerInitialA = tokenA.balanceOf(attacker);
// --- ATTACK BEGINS ---
// 1. FRONT-RUN: Attacker swaps TKA for TKB to drive up the price of TKB.
vm.prank(attacker);
uint256 attackerTokenB = swap.swapAforB(attackerAmountA);
// 2. VICTIM'S TRADE: Victim's transaction executes at the manipulated price.
vm.prank(victim);
uint256 victimTokenB = swap.swapAforB(victimAmountA);
// 3. BACK-RUN: Attacker sells TKB back for TKA to realize their profit.
vm.prank(attacker);
swap.swapBforA(attackerTokenB);
// --- VERIFY RESULTS ---
uint256 attackerProfit = tokenA.balanceOf(attacker) - attackerInitialA;
console.log("Expected TKB for victim:", expectedTokenB / 1e18);
console.log("Victim actually received:", victimTokenB / 1e18);
console.log("Victim's loss:", (expectedTokenB - victimTokenB) / 1e18, "TKB");
console.log("Attacker's profit:", attackerProfit / 1e18, "TKA");
// Assert that the victim received fewer tokens and the attacker profited
assertLt(victimTokenB, expectedTokenB, "Victim should receive fewer tokens");
assertGt(attackerProfit, 0, "Attacker should profit");
}
}
The test results in the following:
Ran 1 test for test/SOL-AM-SandwichAttack-1.t.sol:SandwichAttackTest
[PASS] testSandwichAttack() (gas: 144101)
Logs:
Attacker front-run complete
Expected tokens: 90
Victim received: 63 tokenB
Actual tokens received: 63
Tokens lost due to sandwich: 26
Attacker profit: 30 tokenA
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.82ms (2.95ms CPU time)
The test case simulates the three-step attack sequence precisely:
swapAforB(attackerAmountA)
, swapping 200 tokenA
for tokenB
. This action alters the pool's reserves, making tokenB
more expensive relative to tokenA
.swapAforB(victimAmountA)
. Because the attacker's front-run has already changed the pool's reserves, the AMA’s constant product formula now yields a smaller amount of tokenB
for the victim's 100 tokenA. The victimTokenB
they receive is significantly less than the expectedTokenB
.swapBforA
, selling all the tokenB
they acquired in the front-run. As a result, the attacker realizes a profit and receives more tokenA
back than the 200 tokenA
they started with. The assert statements at the end confirm this: the victim lost value, and the attacker gained it.
To prevent this attack, we must modify the swap function to include a slippage protection parameter. The user should be able to specify the minimum amount of output tokens they are willing to accept.
Here is the corrected SimpleSwap
contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
// This is the secure contract
contract SecureSwap {
// ... state variables and constructor are the same ...
IERC20 public tokenA;
IERC20 public tokenB;
uint256 public reserveA;
uint256 public reserveB;
constructor(IERC20 _tokenA, IERC20 _tokenB, uint256 _reserveA, uint256 _reserveB) {
tokenA = _tokenA;
tokenB = _tokenB;
reserveA = _reserveA;
reserveB = _reserveB;
}
// REMEDIATION: Accept a `minAmountB` parameter to enforce slippage protection.
function swapAforB(uint256 amountA, uint256 minAmountB) external returns (uint256 amountB) {
require(amountA > 0, "Amount must be greater than 0");
uint256 amountInWithFee = amountA * 997;
amountB = (amountInWithFee * reserveB) / (reserveA * 1000 + amountInWithFee);
// The crucial check: revert if the output is less than the user's minimum.
require(amountB >= minAmountB, "Slippage tolerance exceeded");
require(tokenA.transferFrom(msg.sender, address(this), amountA), "Transfer failed");
require(tokenB.transfer(msg.sender, amountB), "Transfer failed");
reserveA += amountA;
reserveB -= amountB;
return amountB;
}
}
The fix is simple but mitigates the sandwich attack effectively:
swapAforB
function now accepts a minAmountB
parameter.amountB
the user will receive, we add a require statement: require(amountB >= minAmountB, "Slippage tolerance exceeded");
.With this change, a sandwich attack is no longer profitable.
If an attacker tries to front-run a trade, the price will shift and the calculated amountB
for the victim will fall. If it falls below their specified minAmountB
, the victim's transaction will revert. When the attack fails, the attacker has spent gas on their front-run transaction and received no gain.
Sandwich attacks are a parasitic form of Miner Extractable Value (MEV) that directly harms users and erodes trust in DeFi protocols. By exploiting the transparency of the mempool and the absence of basic security checks, attackers can consistently extract value from unprotected trades.
As a developer, you can effectively neutralize this threat by implementing robust slippage protection in all price-sensitive user interactions.
By leveraging tools like the Solodit checklist, you can build DeFi applications that are not only functional but also fair and resilient against predatory attacks.
Stay tuned for the next installment of "Solodit Checklist Explained!"