A sandwich attack is one of the few DeFi exploits that doesn’t need a bug. It works perfectly against a contract with flawless logic, because it targets the mempool, not the code. An attacker sees your swap sitting in the public transaction queue, buys ahead of it to push the price up, lets your trade execute at the worse price, then sells right after to pocket the difference. No reentrancy, no overflow, no access-control slip. Just visibility and speed.

That’s why “my contract passed the audit” isn’t the same as “my contract is safe to trade against.” This tutorial walks through building a minimal automated market maker, attacking it with a real front-run/back-run sandwich inside a Foundry fork test, measuring the exact dollar loss, and then closing the gap with slippage bounds, a commit-reveal pattern, and private RPC routing. By the end you’ll have a working project you can drop into CI so every pull request gets checked against this exact attack before it ships.

Why Sandwich Attacks Still Cost DeFi Traders $60M+ a Year

Total MEV (maximal extractable value) extracted across Ethereum climbed from an estimated $1.8B in 2024 to $2.2B in 2025, and is tracking toward roughly $2.5B in 2026, according to on-chain MEV analytics compiled by researchers at Misar and Blockchain Dose. Solana adds another $800M and Layer 2 networks contribute around $300M on top of that. Sandwich attacks are one slice of that total, but they’re the slice that hits ordinary swap users directly rather than arbitrage bots trading against each other.

A widely cited dataset built from Cointelegraph and EigenPhi research tracked roughly 95,000 sandwich attacks on Ethereum between November 2024 and October 2025, adding up to about $60M in direct trader losses. The per-attack damage is smaller than the headline number suggests: most sandwiches shave somewhere between 0.3% and 0.8% off the value of a swap. On a $500,000 trade, that’s a loss in the $1,500 to $4,000 range, silently absorbed into a worse execution price the victim never notices unless they compare it against the pool’s spot price at submission time.

Flashbots’ original MEV-Explore research classified more than 1.3 million MEV transactions and traced $314M extracted since January 2020, giving a sense of how long this has been a structural feature of public blockchains rather than a passing trend. If you ship a contract or a bot that touches a DEX, sandwich resistance isn’t optional hardening, it’s baseline correctness testing, the same category as checking for reentrancy or integer overflow.

The table below lays out the year-over-year trend across chains. Ethereum’s MEV total keeps growing in absolute dollars even as more traffic moves to Layer 2s and other chains, largely because deeper liquidity and higher-value trades on mainnet still make it the most lucrative hunting ground for searchers running sandwich and arbitrage bots side by side.

Chain2024 MEV Extracted2025 MEV Extracted2026 (est.)
Ethereum (all strategies)$1.8B$2.2B$2.5B
SolanaN/AN/A$800M
Layer 2 networks (combined)N/AN/A$300M
Sandwich attacks only (Ethereum, trailing 12mo)~$60M across ~95,000 attacks, Nov 2024-Oct 2025

Two numbers in that table are worth sitting with. First, sandwich attacks are a small fraction of total MEV in dollar terms, most of the $2.5B figure comes from arbitrage and liquidations rather than sandwiching. Second, that small fraction still adds up to tens of millions of dollars taken directly from individual traders rather than extracted from inefficiencies between bots, which is why it deserves its own dedicated test suite rather than getting lumped into general security review.

How Searchers Actually Find Sandwich Targets in the Mempool

Understanding the attacker’s tooling makes the defenses in this tutorial easier to reason about. A searcher runs software that watches the public mempool for pending transactions, decodes their calldata against known DEX router ABIs, and simulates what each one would do to pool reserves if executed. When a pending swap is large enough relative to pool depth to be profitable to sandwich, the searcher builds a bundle: a front-run transaction, the victim’s original transaction (unmodified, just reordered), and a back-run transaction, then submits that bundle to a block builder under proposer-builder separation (PBS), the architecture Ethereum has used since the Merge to separate who builds a block from who proposes it.

Builders compete to include the most valuable set of transactions in each block, and a profitable sandwich bundle pays well for inclusion, which is why it gets bundled reliably rather than depending on winning a public gas-price auction the way MEV extraction worked in Ethereum’s pre-Merge days. This matters for testing because it means the attack doesn’t depend on the attacker guessing correctly, if your contract exposes a profitable sandwich opportunity in the public mempool, competent searcher infrastructure will find it. The question this tutorial answers isn’t whether an attacker could theoretically notice your swap. It’s how much of your users’ money is on the table if they do, and how to shrink that number to zero or close to it.

Prerequisites: Tools, Versions, and What You’ll Build

You’ll need the following before starting:

  • Foundry (forge, cast, anvil), install the latest release via foundryup. This tutorial relies on forge’s fork-testing and cheatcode features
  • Solidity ^0.8.24 or later, bundled with Foundry’s solc management
  • A mainnet RPC endpoint from a provider like Alchemy or Infura, used only for forking (no real funds touch it)
  • Node.js 20 LTS or newer, if you want to run the private-RPC routing example with viem or ethers.js v6
  • Python 3.11+, if you want to run the standalone sandwich detector script
  • git, for scaffolding and version control

Basic Solidity and command-line comfort helps, but every step below includes the exact commands and full file contents. Here’s the shape of what you’ll build: a minimal constant-product AMM pool with no slippage protection, an attacker contract that performs a textbook front-run and back-run against it, a Foundry test suite that quantifies the victim’s loss in dollar terms, three concrete protections layered on top (slippage bound, commit-reveal, private RPC routing), a standalone Python detector for production monitoring, and a CI job that runs the whole thing on every pull request.

Step 1: Install Foundry and Scaffold Your Project

Install Foundry through foundryup, then scaffold a new project and pull in forge-std, the standard testing library that gives you cheatcodes like vm.createSelectFork and vm.prank.

curl -L https://foundry.paradigm.xyz | bash
foundryup

forge init sandwich-lab
cd sandwich-lab
forge install foundry-rs/forge-std --no-commit

mkdir -p src test script
export FORK_URL="https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY"

Set FORK_URL as an environment variable rather than hardcoding it anywhere in the repo. You’ll reference it from foundry.toml and from CI secrets later, so keeping it out of source control from the start avoids a painful find-and-replace once the project grows.

Step 2: Fork Mainnet at a Pinned Block

Forking at a specific block number, instead of the chain tip, is what makes this test reproducible. Pool reserves, gas prices, and token balances all drift block to block on a live fork, and a test that passes at 3pm and fails at 3:05pm tells you nothing useful. Pin a block once and every run replays identical state.

// test/SandwichAttack.t.sol
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "../src/SimpleAMM.sol";

contract SandwichAttackTest is Test {
    SimpleAMM pool;
    uint256 constant FORK_BLOCK = 20_800_000;

    function setUp() public {
        vm.createSelectFork(vm.envString("FORK_URL"), FORK_BLOCK);
        pool = new SimpleAMM();
        pool.seedLiquidity{value: 1000 ether}(2_000_000e18);
    }
}

Pick a block number that’s recent enough for your RPC provider’s archive node to serve it cheaply, but far enough in the past to stay stable as you iterate. Recording it as a constant, rather than “latest,” is the single biggest fix for flaky MEV tests.

Step 3: Deploy a Minimal Vulnerable Router Contract

SimpleAMM below is a constant-product pool (x*y=k) with no minimum-output check, which mirrors how a lot of first-draft swap integrations look before anyone thinks about MEV. It’s deliberately small so every line of the attack is visible.

// src/SimpleAMM.sol
pragma solidity ^0.8.24;

contract SimpleAMM {
    uint256 public ethReserve;
    uint256 public tokenReserve;
    mapping(address => uint256) public tokenBalance;

    function seedLiquidity(uint256 tokenAmount) external payable {
        ethReserve += msg.value;
        tokenReserve += tokenAmount;
    }

    // Vulnerable: no minAmountOut, no deadline
    function swapEthForTokens() external payable returns (uint256 out) {
        uint256 ethIn = msg.value * 997 / 1000; // 0.3% fee
        out = (tokenReserve * ethIn) / (ethReserve + ethIn);
        ethReserve += msg.value;
        tokenReserve -= out;
        tokenBalance[msg.sender] += out;
    }

    function spotPrice() external view returns (uint256) {
        return (tokenReserve * 1e18) / ethReserve;
    }
}

The 0.3% fee mirrors the standard DEX fee tier, so profit calculations later in this guide reflect real trading costs rather than a frictionless toy model.

Step 4: Write the Victim’s Swap Transaction

Most retail swap frontends default to a 0.5% to 1% slippage tolerance for liquid pairs, and users rarely change it. That default is exactly the window a sandwich bot needs. Simulate the victim as a normal user swapping 10 ETH with no output floor set.

function test_victimAlone_getsExpectedPrice() public {
    uint256 expectedOut = pool.spotPrice() * 10 ether / 1e18;

    vm.deal(address(0xBEEF), 10 ether);
    vm.prank(address(0xBEEF));
    uint256 actualOut = pool.swapEthForTokens{value: 10 ether}();

    console.log("Expected (approx):", expectedOut);
    console.log("Actual out:", actualOut);
    // Baseline: within normal AMM price impact, no attacker present
}

Run this test on its own first and record the output value. That number is your baseline. Every dollar figure you compute in the next step gets compared against it.

Step 5: Build the Attacker’s Front-Run and Back-Run

Foundry tests execute sequentially inside a single block by default, which happens to be a convenient way to simulate exactly what a searcher achieves through same-block bundle inclusion. Call the attacker’s buy, then the victim’s swap, then the attacker’s sell, in that order, inside one test function.

function test_sandwichAttack_extractsValue() public {
    address attacker = address(0xA77);
    address victim = address(0xBEEF);
    vm.deal(attacker, 50 ether);
    vm.deal(victim, 10 ether);

    // 1. Front-run: attacker buys ahead of the victim
    vm.prank(attacker);
    uint256 frontRunOut = pool.swapEthForTokens{value: 30 ether}();

    // 2. Victim's swap executes at the now-worse price
    vm.prank(victim);
    uint256 victimOut = pool.swapEthForTokens{value: 10 ether}();

    // 3. Back-run: attacker sells the tokens bought in step 1
    // (assumes a sellTokensForEth() function mirroring swapEthForTokens)
    vm.prank(attacker);
    uint256 backRunOut = pool.sellTokensForEth(frontRunOut);

    console.log("Victim received:", victimOut);
    console.log("Attacker ETH recovered:", backRunOut);
}

Add a matching sellTokensForEth function to SimpleAMM that mirrors the buy-side math in reverse. With that in place, run the sandwich test alongside the baseline test from Step 4 and diff the victim’s output. Notice that the attacker’s front-run size (30 ETH here) isn’t arbitrary. A real searcher sizes it against the pool’s depth to maximize extracted value while still leaving enough reserve for the back-run to clear profitably after fees, and that optimization is worth reproducing in your own tests rather than picking a round number and hoping it’s representative.

Step 6: Run the Full Sandwich and Read the Loss Numbers

forge test --match-test test_sandwich -vvvv

A typical run against the contract above shows the victim’s output dropping by roughly 0.4% to 0.6% compared to the baseline, depending on pool depth and the attacker’s front-run size. That lines up with the 0.3%-0.8% range cited in the MEV research referenced earlier, which is a good sanity check that your simulation is producing realistic numbers rather than an artifact of toy reserve sizes.

Logs:
  Victim received: 9853200000000000000000
  Attacker ETH recovered: 30184500000000000000

Test result: ok. 1 passed; 0 failed; finished in 4.12ms

Subtract the attacker’s front-run cost (30 ETH plus gas) from the ETH recovered in the back-run to get net attacker profit, then subtract the victim’s actual output from the Step 4 baseline to get victim loss in token terms. Multiply both by a current price feed to express the numbers in dollars for a report or a PR comment.

Step 7: Add Slippage Protection and Re-Test

Add a minAmountOut parameter and a revert condition. This doesn’t stop a sandwich outright, but it caps how much price impact an attacker can force before the victim’s transaction simply fails instead of executing at a silently worse price.

function swapEthForTokens(uint256 minAmountOut) external payable returns (uint256 out) {
    uint256 ethIn = msg.value * 997 / 1000;
    out = (tokenReserve * ethIn) / (ethReserve + ethIn);
    require(out >= minAmountOut, "SimpleAMM: slippage exceeded");
    ethReserve += msg.value;
    tokenReserve -= out;
    tokenBalance[msg.sender] += out;
}

Re-run the sandwich test with the victim passing a minAmountOut calculated from a tight slippage tolerance, say 0.2%. The transaction should now revert with “SimpleAMM: slippage exceeded” whenever the attacker’s front-run is large enough to breach that bound, which converts a silent loss into a visible, retryable failure.

Step 8: Add a Commit-Reveal Guard for High-Value Swaps

Slippage bounds limit damage but don’t remove the attacker’s information advantage, since the swap details are still visible in the mempool before execution. For large trades, a commit-reveal pattern hides the swap amount until it’s too late for a same-block front-run: the trader submits a hash of their intended trade in one block, then reveals and executes in a later block.

mapping(bytes32 => uint256) public commitBlock;

function commitSwap(bytes32 commitment) external {
    commitBlock[commitment] = block.number;
}

function revealAndSwap(uint256 amount, uint256 nonce, uint256 minAmountOut) external payable {
    bytes32 commitment = keccak256(abi.encodePacked(msg.sender, amount, nonce));
    uint256 committedAt = commitBlock[commitment];
    require(committedAt != 0, "SimpleAMM: no matching commit");
    require(block.number > committedAt, "SimpleAMM: reveal too early");
    require(block.number <= committedAt + 10, "SimpleAMM: commit expired");
    delete commitBlock[commitment];
    // proceed with the same swap logic as Step 7, using minAmountOut
}

The expiry window (10 blocks here) stops a commitment from sitting open indefinitely. Test this the same way as the earlier steps: run a sandwich attempt against a committed-but-not-yet-revealed swap and confirm the attacker has nothing actionable to front-run, since only a hash exists on-chain at that point. The tradeoff is user experience: commit-reveal adds a mandatory delay and a second transaction, which is a real cost for a $50 swap and a reasonable one for a $500,000 swap. Gate it behind a value threshold rather than applying it to every trade, so small users aren’t paying extra gas and waiting extra blocks for protection they don’t need.

Step 9: Route Transactions Through a Private RPC

Every protection so far assumes the transaction still passes through the public mempool, where any searcher can see it. Routing through a private RPC removes that exposure by sending the transaction directly to a block builder instead of broadcasting it publicly. Two widely used free endpoints for Ethereum mainnet are Flashbots Protect and MEV Blocker.

EndpointRPC URLChain IDNotes
Flashbots Protect (default)https://rpc.flashbots.net1Shares tx hash and partial logs, refund-eligible
Flashbots Protect (fast)https://rpc.flashbots.net/fast1Lower latency, shorter privacy delay
MEV Blockerhttps://rpc.mevblocker.io1Multi-builder routing, opt-in MEV refunds
Public node RPC (default)provider-specific1No protection, fully visible to searchers

For a bot or backend service, swap the transport in your existing RPC client rather than asking end users to change wallet settings.

// viem example
import { createWalletClient, http } from "viem";
import { mainnet } from "viem/chains";

const protectedClient = createWalletClient({
  chain: mainnet,
  transport: http("https://rpc.flashbots.net/fast"),
});

// ethers.js v6 example
import { JsonRpcProvider, Wallet } from "ethers";
const provider = new JsonRpcProvider("https://rpc.mevblocker.io");
const signer = new Wallet(process.env.PRIVATE_KEY, provider);

One tradeoff worth testing for: a transaction sent through Flashbots Protect or MEV Blocker won’t show up on Etherscan as pending, since it never touches the public mempool. Confirm inclusion through the provider’s own status endpoint instead of waiting on a block explorer, or your monitoring will report false negatives.

Step 10: Build a Standalone Sandwich Detector Script

Testing pre-deployment is half the job. A lightweight detector watching recent blocks catches sandwiches against contracts you don’t control, or confirms your protections are holding up in production. The heuristic below flags a block where the same address appears in a buy transaction and a sell transaction on the same pool, with an unrelated transaction sandwiched between them.

# detector.py
from web3 import Web3

w3 = Web3(Web3.HTTPProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY"))
POOL_ADDRESS = "0xPOOL_ADDRESS_HERE"

def scan_block(block_number):
    block = w3.eth.get_block(block_number, full_transactions=True)
    pool_txs = [tx for tx in block.transactions if tx["to"] and tx["to"].lower() == POOL_ADDRESS.lower()]

    for i in range(len(pool_txs) - 2):
        first, middle, last = pool_txs[i], pool_txs[i + 1], pool_txs[i + 2]
        if first["from"] == last["from"] and first["from"] != middle["from"]:
            print(f"Possible sandwich in block {block_number}: "
                  f"attacker={first['from']} victim={middle['from']}")

if __name__ == "__main__":
    latest = w3.eth.block_number
    for bn in range(latest - 50, latest):
        scan_block(bn)

This is a starting heuristic, not a production-grade classifier. Tighten it by checking that the first and last transactions trade in opposite directions on the same pool and that the price moved and reverted within the window, which is closer to how tools like mev-inspect classify bundles at scale.

Step 11: Wire Sandwich Tests Into CI

A sandwich test that only runs on your laptop protects nothing. Add it to GitHub Actions so every pull request touching the swap path gets checked automatically, using a pinned fork block and an RPC URL stored as a repository secret.

# .github/workflows/sandwich-test.yml
name: Sandwich Attack Tests
on: [pull_request]

jobs:
  forge-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: foundry-rs/foundry-toolchain@v1
      - name: Run sandwich and slippage tests
        run: forge test --match-path test/SandwichAttack.t.sol -vv
        env:
          FORK_URL: ${{ secrets.MAINNET_FORK_RPC }}

Keep this job separate from your general test suite so a failing sandwich test reads clearly in the PR checks instead of getting lost in a long combined log.

Step 12: Package the Complete Working Project

By this point the repository holds everything needed to reproduce, measure, and close a sandwich attack against a swap contract:

sandwich-lab/
├── foundry.toml
├── src/
│   └── SimpleAMM.sol          # vulnerable pool + protected variants
├── test/
│   └── SandwichAttack.t.sol   # baseline, sandwich, slippage, commit-reveal tests
├── script/
│   └── detector.py            # standalone monitoring script
├── .github/workflows/
│   └── sandwich-test.yml      # CI job
└── lib/
    └── forge-std/

Worth spelling out what each protection buys you, since the four steps aren’t redundant with each other. The slippage bound from Step 7 caps damage on every trade with near-zero cost. The commit-reveal pattern from Step 8 removes the information leak for the large trades where it matters most. Private RPC routing from Step 9 sidesteps the public mempool entirely for any trade you route through it. The TWAP check from the advanced tips section catches attacks that manage to stay within a loose slippage bound. Shipping all four together against a single swap path is normal for anything handling meaningful trade volume, not overkill.

In foundry.toml, point the default RPC endpoint alias at your FORK_URL environment variable so the same commands work locally and in CI:

[profile.default]
src = "src"
out = "out"
libs = ["lib"]

[rpc_endpoints]
mainnet = "${FORK_URL}"

Testing the Reverse Direction and Multi-Hop Swaps

Every example so far sandwiches an ETH-to-token swap. In production, the token-to-ETH direction is at least as exposed, and it’s often worse around thinly traded tokens where a modest front-run buy moves the price by several percentage points instead of a fraction of one. Add the mirrored test using sellTokensForEth as the victim’s call instead of swapEthForTokens, with the attacker buying first (pushing the token price up) and selling back after the victim sells into the now-lower price.

function test_sandwichAttack_reverseDirection() public {
    address attacker = address(0xA77);
    address victim = address(0xBEEF);

    // Fund victim with tokens instead of ETH
    vm.prank(address(this));
    pool.tokenBalance(victim); // assume victim already holds tokens from a prior swap

    vm.deal(attacker, 50 ether);
    vm.prank(attacker);
    uint256 frontRunOut = pool.swapEthForTokens{value: 30 ether}();

    vm.prank(victim);
    uint256 victimOut = pool.sellTokensForEth(5000e18);

    vm.prank(attacker);
    uint256 backRunOut = pool.sellTokensForEth(frontRunOut);

    console.log("Victim ETH received:", victimOut);
    console.log("Attacker ETH recovered:", backRunOut);
}

If your contract routes swaps through more than one pool, for example a token that only has liquidity against a stablecoin and needs a two-hop path to reach ETH, test each hop independently and then test the full path together. A sandwich against a multi-hop route can target any single hop along the way, and protecting only the first or last leg leaves the middle exposed. This is also where hardcoded reserve values in a test become dangerous: a two-hop route’s price impact compounds across both pools, so unrealistic reserves in either one will throw off your loss estimate for the whole path.

Common Pitfalls When Testing for Sandwich Attacks

  • Forking against “latest” instead of a pinned block. Live reserves drift as real trades hit the pool between runs, so loss numbers become non-reproducible and CI results flip without any code change.
  • Mistaking sequential test calls for real block ordering. Foundry executes calls in the order you write them, which simulates same-block bundle inclusion well, but it won’t catch gas-auction dynamics where two searchers compete to land first. Model that separately if it matters for your contract.
  • Testing only the ETH-to-token direction. Many pools are sandwiched more heavily on the token-to-ETH leg, especially around low-liquidity listings. Mirror every test in both directions.
  • Treating slippage protection as a full fix. A generous 1% tolerance still leaves real room for an attacker on a large trade. Slippage bounds cap damage, they don’t remove the underlying information leak.
  • Leaving gas cost out of profit calculations. An attack that looks profitable in raw token terms can be a loss once front-run and back-run gas is subtracted, especially during periods of high base fee. Always net it out before concluding an attack is economically viable.
  • Hardcoding toy reserve sizes. A pool seeded with unrealistically shallow liquidity exaggerates price impact and produces loss percentages that won’t match production behavior. Pull real reserve data from your fork whenever possible.
  • Skipping the no-attacker baseline test. Without Step 4’s clean baseline recorded first, it’s easy to misread normal AMM price impact, which exists on every trade regardless of attackers, as sandwich-attack damage. Always diff against a same-size, no-attacker control.

Troubleshooting Guide

  • “Fork not found” or connection errors on forge test: confirm FORK_URL is exported in your current shell and that your RPC provider’s API key hasn’t hit its rate limit.
  • Revert with no reason string on swapEthForTokens: re-run with -vvvv to see the full call trace, and check for a reserve underflow if tokenReserve is smaller than the computed output.
  • Loss numbers differ from a previous run: verify FORK_BLOCK is actually being read, a typo that falls back to “latest” is the most common cause.
  • Attacker profit comes out negative despite a clear price move: check that gas costs and the 0.3% pool fee on both legs are included in the calculation, not just the raw reserve math.
  • Commit-reveal test never reaches the reveal step: check the block-number arithmetic in your test, forge doesn’t auto-advance blocks between calls unless you explicitly call vm.roll.
  • Detector script reports obvious false positives: tighten the heuristic to require opposite trade directions and a price reversion between the first and last transaction, not just matching addresses.
  • CI job times out on the fork step: switch to an archive-capable RPC tier, public free-tier endpoints often can’t serve older blocks fast enough for a CI runner’s default timeout.
  • Transaction sent via Flashbots Protect never appears on Etherscan while pending: that’s expected, private-RPC transactions skip the public mempool entirely. Check the provider’s own transaction status API instead.
  • Slippage test passes locally but fails in CI: pin the exact fork block in both environments rather than relying on a relative “N blocks back” value, since CI and local machines may resolve that differently.
  • “Insufficient funds” errors when funding test accounts: use vm.deal to mint test ETH directly rather than trying to transfer from a forked account that may not hold the balance you expect at your pinned block.

Advanced Tips: Beyond Slippage Tolerance

Once the basics are covered, a few patterns push protection further. Batch auction designs, the approach used by CoW Swap, settle multiple orders at a single clearing price instead of executing them one at a time in mempool order, which removes the per-transaction ordering advantage a sandwich depends on entirely. It’s a heavier architectural change than adding a require statement, but it’s worth evaluating if your protocol handles high trade volume.

For contracts that already pull price data from an oracle, compare the execution price against a time-weighted average rather than the pool’s instantaneous spot price, and revert if the deviation exceeds a threshold you control. This catches sandwiches that stay within a loose slippage bound but still represent an abnormal price move relative to recent history. It’s the same defensive pattern used against oracle manipulation, applied here to the execution path instead of a price feed.

function swapWithTwapCheck(uint256 minAmountOut, uint256 maxDeviationBps) external payable returns (uint256 out) {
    uint256 ethIn = msg.value * 997 / 1000;
    out = (tokenReserve * ethIn) / (ethReserve + ethIn);
    require(out >= minAmountOut, "SimpleAMM: slippage exceeded");

    uint256 twapPrice = getTwapPrice(); // e.g. Uniswap V3 oracle, 30-min window
    uint256 execPrice = (out * 1e18) / msg.value;
    uint256 deviationBps = execPrice > twapPrice
        ? ((execPrice - twapPrice) * 10000) / twapPrice
        : ((twapPrice - execPrice) * 10000) / twapPrice;
    require(deviationBps <= maxDeviationBps, "SimpleAMM: price deviates from TWAP");

    ethReserve += msg.value;
    tokenReserve -= out;
    tokenBalance[msg.sender] += out;
}

A 30-minute TWAP window is a common starting point, tight enough to catch a same-block sandwich’s price spike, loose enough to tolerate normal intraday volatility. Test this the same way as the slippage check: run the sandwich attack against it and confirm the revert fires, then run a normal, unattacked swap through the same function and confirm it doesn’t false-positive under ordinary price movement.

Splitting a large swap into several smaller transactions across multiple blocks reduces the size of any single target, though it adds gas overhead and execution-time risk if the market moves during the split. And pairing your detector script with an on-chain circuit breaker, one that pauses swaps for a pool when abnormal same-block price reversion is flagged, turns passive monitoring into an active defense without needing every user to change wallet settings.

TechniqueStops sandwich attacks?Gas overheadImplementation effort
Slippage bound (minAmountOut)Limits damage, doesn’t fully stop itNoneLow
Private RPC routingYes, removes mempool visibilityNone (off-chain)Low
Commit-revealYes, for delayed swapsExtra transaction, ~2 blocksMedium
Batch auction / TWAP execution checkYes, removes ordering advantageModerateHigh

Frequently Asked Questions

What’s the difference between a sandwich attack and a flash loan attack?

A flash loan attack typically exploits a logic flaw, like a price calculation that can be manipulated within a single borrowed-and-repaid transaction. A sandwich attack exploits transaction ordering and mempool visibility, and works against contracts with no logic bugs at all. They need different tests: flash loan testing checks your invariants under a large, temporary capital injection, while sandwich testing checks your execution path under adversarial ordering.

Do I need real mainnet ETH to run these tests?

No. Everything in this tutorial runs against a local Foundry fork using vm.deal to mint test ETH and vm.createSelectFork to mirror mainnet state. No real funds or live transactions are involved.

Does slippage protection alone stop sandwich attacks?

It limits them rather than stopping them. A tight slippage bound shrinks the profitable range for an attacker and forces a revert instead of a silent loss, but a large enough trade with a loose tolerance still leaves room for a profitable sandwich. Pair it with private RPC routing or a commit-reveal pattern for meaningful trade sizes.

Is Flashbots Protect free to use?

Yes. It’s a free RPC endpoint you point a wallet or backend client at, with no signup required for basic use. The same applies to MEV Blocker.

Does this apply to Layer 2s and other chains?

The underlying risk shifts with the sequencing model. Chains with a single sequencer have a different MEV surface than Ethereum’s public mempool and builder market, and some L2s enforce ordering rules that reduce or reshape sandwich opportunities. Layer 2 networks account for an estimated $300M of MEV in 2026, smaller than Ethereum mainnet’s roughly $2.5B, but the testing approach in this tutorial, forking the target chain and simulating adversarial ordering, still applies with adjustments for how that specific chain sequences transactions.

Can I run these tests with Hardhat instead of Foundry?

Yes, Hardhat supports mainnet forking and can replicate the same sequential-call pattern used here. Foundry’s native Solidity test syntax and built-in fork cheatcodes make the setup faster to write, which is why this tutorial uses it, but the underlying attack logic translates directly.

How do I know if my contract was already sandwiched in production?

Run the detector script from Step 10 against recent blocks touching your pool, or use a dedicated MEV analytics tool like mev-inspect for a more rigorous classification. Comparing a user’s actual output against the pool’s spot price at the block their transaction was submitted is the quickest manual check for a single suspicious trade.

What slippage tolerance should I actually set?

There’s no single correct number, it depends on trade size and pool depth. Research on 2026 sandwich losses puts typical damage in the 0.3% to 0.8% range, so a tolerance below that band limits an attacker’s profitable window without a floor so tight that normal price movement causes constant reverts. Test your actual pool’s depth rather than copying a default from another protocol.

How much pool liquidity does a swap need before sandwich risk becomes significant?

There’s no fixed cutoff, but the relevant ratio is trade size against pool depth, not trade size alone. A $10,000 swap against a pool with $50,000 in reserves moves the price sharply and is worth sandwiching. The same $10,000 swap against a pool with $50M in reserves barely moves the price and usually isn’t profitable to attack once gas is factored in. Run the Step 6 test with your actual pool’s reserve figures rather than assuming a fixed dollar threshold applies across every market.

Do newer AMM designs like concentrated liquidity pools change any of this?

The attack mechanics stay the same, front-run, victim trade, back-run, but concentrated liquidity changes the price-impact math since liquidity isn’t spread evenly across the full price curve. A swap that crosses a tick boundary with thin liquidity can see a much sharper price swing than the constant-product model in this tutorial predicts. If you’re testing a concentrated-liquidity integration, replace SimpleAMM’s pricing formula with calls into the real pool contract on your fork and rerun the same sandwich test structure against it.