Back to blogs
Written by
Hans
Published on
August 4, 2025

Solodit Checklist Explained (11): Sandwich Attacks

Sandwich attacks occur when attackers use public mempools to manipulate price and trading activity. Learn to identify them and how slippage protection can protect users.

Table of Contents

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.

Stylized illustration of a cute red panda eating a sandwich while sitting on a bench in a park.

Understanding Sandwich Attacks

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:

  1. Observation: The attacker monitors a DEX’s mempool for large, pending trades.
  2. Front-running: The attacker submits their own trade for the same asset pair before the victim's transaction and paying a higher gas fee to ensure their transaction is processed first. This initial trade increases the asset's price.
  3. Back-running: After the victim's trade executes at the now-inflated price, the attacker immediately sells their newly acquired assets. The second trade, placed right after the victim's, allows the attacker to cash out and profit from the temporary price swing they engineered.

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.

SOL-AM-SandwichAttack-1: Does the protocol have explicit slippage protection on user interactions?

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)

How the Attack Works

The test case simulates the three-step attack sequence precisely:

  1. The Front-Run: The attacker calls swapAforB(attackerAmountA), swapping 200 tokenA for tokenB. This action alters the pool's reserves, making tokenB more expensive relative to tokenA.
  2. The Victim’s Trade: The victim calls 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.
  3. The Back-Run: The victim's trade has further shifted the price in the attacker's favor and the attacker immediately calls 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.

Remediation: Enforce slippage protection

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:

  1. The swapAforB function now accepts a minAmountB parameter.
  2. After calculating the 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.

Conclusion

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!"

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.