Back to blogs
Written by
Hans
Published on
May 1, 2025

Solodit Checklist Explained (6): Miner Attacks

Learn how validators exploit block.timestamp, randomness, and tx ordering in smart contracts. Secure your code from these subtle blockchain attacks.

Table of Contents

Today, we're diving into Miner Attack. Validators (formerly known as miners in Proof-of-Work (PoW) systems) are transaction processors who possess considerable control over blockchain mechanics. Understanding their inherent privileges within consensus protocols is essential for implementing secure smart contracts.

We'll explore scenarios where validators can influence contract execution. Not by outright hacking, but by leveraging their position in the blockchain's consensus mechanism.

Specifically, we'll tackle these checklist items:

  • SOL-AM-MA-1: Is block.timestamp used for time-sensitive operations?
  • SOL-AM-MA-2: Is the contract using block properties like timestamp or difficulty for randomness generation?
  • SOL-AM-MA-3: Is contract logic sensitive to transaction ordering?

Let's dive in and uncover how to protect your smart contracts from these subtle, yet potentially devastating, attacks!

For the best experience, open a tab with the Solodit checklist to refer to it.

Note
: We have previously covered topics including denial-of-service (part 1, part 2), donation attacks, front-running attacks, and griefing attacks. Make sure to check them out. 

Miner influence in blockchain systems

Miners are pivotal in PoW blockchain networks, validating transactions and securing the network through a competitive process of solving cryptographic puzzles. This process, known as mining, requires significant computational resources and energy to generate valid PoW solutions that meet the network's difficulty target.

The evolution of Ethereum from PoW to Proof-of-Stake (PoS) has transformed "miners" into "validators." Yet, the blockchain security community continues to use terms like "miner extractable value" and "miner manipulation" due to their historical prevalence and technical continuity. However, these attacks are now executed by validators who control transaction ordering in the post-Merge Ethereum ecosystem.

Miners possess several protocol-granted capabilities that directly impact blockchain operation. They determine transaction inclusion and ordering within blocks based on fee incentives (gas prices in Ethereum), giving them control over the mempool processing queue. This ordering power enables the profit capture of maximal extractable value (MEV) through strategic transaction positioning without violating consensus rules. Miners can manipulate block timestamps within network-defined tolerances, typically ±15 seconds in Ethereum and up to 2 hours in Bitcoin, which can potentially affect time-dependent contract logic, such as unlocking periods or interest calculations.

Additionally, miners influence block properties, including gas limits, and determine blockhash values through their mining activity. These properties become part of the immutable blockchain state and can affect contracts that rely on them for functionality.

We focus primarily on miner manipulations that can be executed without controlling a majority of the hashrate. Specifically, those related to block timestamps, block properties used for randomness generation, and transaction ordering vulnerabilities. These exploits can be performed by miners with modest network participation and represent practical attack vectors against deployed smart contracts.

While other interesting attack vectors exist, including:

  • 51% attacks: Controlling the majority of the hashrate to enable double-spending.
  • Selfish mining: Strategically withholding blocks to increase relative rewards.
  • Timejacking: Manipulating network time perception.
  • Eclipse attacks: Isolating nodes from honest peers)


Yet, these typically require either substantial computational resources or sophisticated network control mechanisms beyond standard mining operations. Such attacks target consensus-layer vulnerabilities rather than application-layer contract exploits and thus fall outside our immediate scope of smart contract security considerations.

SOL-AM-MA-1: Is block.timestamp used for time-sensitive operations?

  • Description: Miners can manipulate block.timestamp by several seconds, potentially affecting time-dependent contract logic.

  • Remediation: Use block.number instead of timestamps for critical timing operations, or ensure that manipulation tolerance is acceptable.

block.timestamp represents the time the miner proposes for the block. While generally accurate, miners have some leeway to adjust it. This leeway allows potential vulnerabilities in time-sensitive logic, such as auctions or staking periods. 

This vulnerability is a direct consequence of miner manipulation, reflecting the ability of miners to influence the creation of blocks. While miners can't set arbitrary timestamps, they do have some control within the consensus rules. On Ethereum, this is approximately +/- 900 seconds (15 minutes), though the exact range can vary by blockchain. 

Imagine an auction where a miner shifts the timestamp to favor their bid or that of a confederate. This could lead to unfair advantages, such as prematurely ending the auction or manipulating the bidding process.

Let's look at an auction example:

pragma solidity ^0.8.0;


// SPDX-License-Identifier: UNLICENSED
contract Auction {
    uint public auctionEndTime;
    address public highestBidder;
    uint public highestBid;
    mapping(address => uint) public pendingReturns;
    bool public ended;


    event BidPlaced(address bidder, uint amount);
    event AuctionEnded(address winner, uint amount);


    constructor(uint _duration) {
        auctionEndTime = block.timestamp + _duration; // Vulnerability!
    }


    function isAuctionEnded() public view returns (bool) {
        return block.timestamp >= auctionEndTime; // Vulnerable comparison!
    }


    function bid() public payable {
        require(!isAuctionEnded(), "Auction has ended");
        require(msg.value > highestBid, "Bid not high enough");


        if (highestBid > 0) {
            pendingReturns[highestBidder] += highestBid;
        }


        highestBidder = msg.sender;
        highestBid = msg.value;
        emit BidPlaced(msg.sender, msg.value);
    }


    function endAuction() public {
        require(!ended, "Auction already ended");
        require(isAuctionEnded(), "Auction not yet ended");


        ended = true;
        emit AuctionEnded(highestBidder, highestBid);
    }


    function withdraw() public returns (bool) {
        uint amount = pendingReturns[msg.sender];
        if (amount > 0) {
            pendingReturns[msg.sender] = 0;


            // Use call instead of transfer for better compatibility
            (bool success, ) = payable(msg.sender).call{value: amount}("");
            require(success, "Transfer failed");
        }
        return true;
    }
}


In this Auction contract, the auctionEndTime is determined by block.timestamp + _duration. A miner could subtly tweak the block.timestamp to finalize the auction prematurely or delay it. They might delay it just enough to allow their bid to be included in the block, or advance it to exclude a competing bid.

Here's a test that exposes the problem using Foundry:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;


import "forge-std/Test.sol";


/**
 * Overview:
 * Checklist Item ID: SOL-AM-MA-1
 *
 * This test demonstrates the vulnerability of using block.timestamp for time-sensitive operations in an auction.
 *
 * VULNERABILITY EXPLANATION:
 * 1. Miners can manipulate block.timestamp by several seconds (typically up to 900 seconds/15 minutes)
 * 2. In high-value auctions, a miner could slightly advance the timestamp to prematurely end an auction
 * 3. Critical financial decisions (where a second matters) should not rely on block.timestamp precision
 */
contract AuctionTest is Test {
    Auction public auction;
    uint256 initialDuration = 1 days;
    address bidder1 = address(0x1);
    address bidder2 = address(0x2);
    address minerBidder = address(0x3);


    function setUp() public {
        auction = new Auction(initialDuration);
        vm.deal(bidder1, 10 ether);
        vm.deal(bidder2, 10 ether);
        vm.deal(minerBidder, 10 ether);
    }


    function testRealisticTimestampManipulation() public {
        // Bidder1 places initial bid
        vm.prank(bidder1);
        auction.bid{value: 1 ether}();
        assertEq(auction.highestBidder(), bidder1);


        // Fast forward to near the end of auction (just 30 seconds remaining)
        vm.warp(block.timestamp + initialDuration - 30);


        // MinerBidder places bid
        vm.prank(minerBidder);
        auction.bid{value: 1.5 ether}();
        assertEq(auction.highestBidder(), minerBidder);


        // Bidder2 attempts to place a last-second bid,
        // but miner manipulates timestamp by just 31 seconds
        // NOTE: This is a realistic manipulation that could occur in practice
        vm.warp(block.timestamp + 31); // Just enough to end the auction


        // Bidder2's transaction fails because the miner-manipulated
        // timestamp has passed the auction end time
        vm.prank(bidder2);
        vm.expectRevert("Auction has ended");
        auction.bid{value: 2 ether}();


        // MinerBidder ends auction and wins despite Bidder2's higher bid
        vm.prank(minerBidder);
        auction.endAuction();


        // Verify miner won unfairly by manipulating timestamp
        assertEq(auction.highestBidder(), minerBidder);
        assertEq(auction.highestBid(), 1.5 ether);
    }
}


This example highlights how miners can adjust block.timestamp within the consensus rules, which could potentially affect the auction's outcome. In the provided test, a miner advances the timestamp by 31 seconds to prematurely end the auction, preventing a higher bid from being accepted and unfairly awarding the auction to themselves.

Mitigation:

Instead of relying directly on block.timestamp, consider using block.number.

pragma solidity ^0.8.0;


contract FixedAuction {
    uint256 public auctionEndBlock;
    uint256 public blockDuration;


    constructor(uint256 _blockDuration) {
        auctionEndBlock = block.number + _blockDuration;
        blockDuration = _blockDuration; // Duration of auction in blocks
    }


    function finalizeAuction() public {
        require(block.number >= auctionEndBlock, "Auction is not yet over.");
        // Distribute funds
    }
}


In this FixedAuction example, we use block.number instead of block.timestamp to determine the auction's end. This makes the auction's timing predictable and resistant to miner manipulation of block.timestamp.

However, a miner could choose not to mine blocks at all, which would delay the auction end, although this is a less direct form of manipulation. Also, using block.number makes the auction dependent on the rate of block creation. If block times are inconsistent, the auction duration may vary slightly in real-world time.

If more granular and consistent time is needed, consider using a Chainlink oracle, but be aware of the increased complexity and gas costs.

In general, it is recommended to avoid designing features that are sensitive to time at the seconds level, as miners can manipulate block timestamps within tolerances (typically ±15 seconds in Ethereum). Smart contracts should implement time-based mechanisms with sufficient buffer periods to prevent economic exploitation through minor timestamp adjustments.

SOL-AM-MA-2: Is the contract using block properties like timestamp or difficulty for randomness generation?

  • Description: Block properties (timestamp, difficulty) and other predictable values should not be used for randomness as they can be influenced or predicted by miners.

  • Remediation: Use a secure randomness source, such as Chainlink VRF, commit-reveal schemes, or a provably fair randomization mechanism, instead.


True randomness on a blockchain is hard. block.timestamp and block.difficulty may seem random at first glance, but miners can influence them, making the outcome predictable. 

Miners can manipulate block.timestamp within consensus rules, and while they have very limited short-term control over block.difficulty, relying on these for security-critical logic is generally a bad idea. 

Imagine a lottery where the miner knows the winning number beforehand. Not exactly a fair game. Again, this falls under miner manipulation, where miners leverage their influence to gain an advantage.

Let's look at a flawed lottery contract:

pragma solidity ^0.8.0;


// SPDX-License-Identifier: UNLICENSED


contract Lottery {
    address public winner;


    function pickWinner() public {
        // Vulnerable randomness generation using block.timestamp
        uint256 randomNumber = uint256(block.timestamp) % 100;
        if (randomNumber == 7) {
            winner = msg.sender;
        } else {
            winner = address(0);
        }
    }


    function getBlockTimestamp() public view returns (uint256) {
        return block.timestamp;
    }
}


Here, pickWinner uses block.timestamp to generate a "random" number. A malicious miner can adjust the timestamp of the block containing the pickWinner transaction, influencing the outcome and rigging the lottery.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;


import "forge-std/Test.sol";


/**
 * Overview:
 * Checklist Item ID: SOL-AM-MA-2
 *
 * This test demonstrates how using `block.timestamp` for randomness in a lottery contract can be exploited by miners.
 * A miner can manipulate the `block.timestamp` to influence the outcome of the randomNumber and potentially win the lottery.
 * The test attempts to call the pickWinner function repeatedly in the same block to find desired 'randomNumber' by manipulating block timestamp
 */
contract LotteryTest is Test {
    Lottery public lottery;
    address public attacker = address(0x123);


    function setUp() public {
        lottery = new Lottery();
        vm.deal(attacker, 1 ether);
    }


    function testPredictableRandomness() public {
        vm.startPrank(attacker);


        uint256 initialTimestamp = block.timestamp;
        bool winnerFound = false;


        // Try timestamps close to current to find a winning timestamp.
        for (uint256 i = 0; i < 10; i++) {
            // Slightly modify the timestamp
            uint256 manipulatedTimestamp = initialTimestamp + i;


            // Manually set the block timestamp for the next call.
            vm.warp(manipulatedTimestamp);


            lottery.pickWinner();
            if (lottery.winner() == attacker) {
                winnerFound = true;
                break;
            }
        }


        assertTrue(winnerFound, "Attacker should be able to manipulate timestamp to win");
        vm.stopPrank();
    }
}


This test shows that a miner can control the outcome by changing the block timestamp within a small range to get their desired results. In the test, the pickWinner function is called repeatedly with slightly modified timestamps until the attacker's address is chosen as the winner. This makes the lottery incredibly unfair to participants.

Mitigation
:

Instead of relying on easily manipulated block properties, use a secure randomness source like Chainlink VRF, which provides a verifiable and unpredictable random number, ensuring fairness and preventing manipulation. Block properties such as block.prevrandao are also better than block.timestamp, but should still not be relied on due to the potential for deprecation.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;


import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";


/**
 * @title SecureLottery
 * @notice A lottery contract using Chainlink VRF V2.5 for verifiable randomness
 * @dev This is an example contract and should not be used in production without proper auditing
 */
contract SecureLottery is VRFConsumerBaseV2Plus {
    // Chainlink VRF configuration
    uint256 public s_subscriptionId;
    bytes32 public keyHash = 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae; // Sepolia gas lane
    uint32 public callbackGasLimit = 100000;
    uint16 public requestConfirmations = 3;
    uint32 public numWords = 1;


    // Lottery state variables
    uint256 public randomNumber;
    address public winner;
    mapping(uint256 => bool) public requestIds;
    uint256 public lastRequestId;


    // Events
    event RandomnessRequested(uint256 requestId);
    event WinnerSelected(address winner, uint256 randomNumber);


    /**
     * @param subscriptionId Chainlink VRF subscription ID
     * @param vrfCoordinator Address of the VRF Coordinator contract
     */
    constructor(
        uint256 subscriptionId,
        address vrfCoordinator
    ) VRFConsumerBaseV2Plus(vrfCoordinator) {
        s_subscriptionId = subscriptionId;
    }


    /**
     * @notice Request random number from Chainlink VRF
     * @param useNativePayment Whether to pay in native tokens (true) or LINK (false)
     * @return requestId The ID of the randomness request
     */
    function requestRandomWinner(bool useNativePayment) external returns (uint256 requestId) {
        // Request randomness from Chainlink VRF
        requestId = s_vrfCoordinator.requestRandomWords(
            VRFV2PlusClient.RandomWordsRequest({
                keyHash: keyHash,
                subId: s_subscriptionId,
                requestConfirmations: requestConfirmations,
                callbackGasLimit: callbackGasLimit,
                numWords: numWords,
                extraArgs: VRFV2PlusClient._argsToBytes(
                    VRFV2PlusClient.ExtraArgsV1({
                        nativePayment: useNativePayment
                    })
                )
            })
        );


        requestIds[requestId] = true;
        lastRequestId = requestId;
        emit RandomnessRequested(requestId);
        return requestId;
    }


    /**
     * @notice Callback function called by VRF Coordinator when randomness is fulfilled
     * @param requestId The ID of the randomness request
     * @param randomWords The random words generated by Chainlink VRF
     */
    function fulfillRandomWords(
        uint256 requestId,
        uint256[] calldata randomWords
    ) internal override {
        require(requestIds[requestId], "Request not found");
        require(randomWords.length > 0, "Random words array is empty");


        // Process the random value
        randomNumber = randomWords[0] % 100; // Example: limit to 0-99 range


        // Lottery winner selection logic would go here
        // For example, if you have participants in an array:
        // winner = participants[randomNumber % participants.length];


        emit WinnerSelected(winner, randomNumber);
    }


    /**
     * @notice Get the status of a VRF request
     * @param requestId The ID of the randomness request
     * @return exists Whether the request exists
     */
    function getRequestStatus(uint256 requestId) external view returns (bool exists) {
        return requestIds[requestId];
    }
}


This SecureLottery contract uses Chainlink VRF to generate a truly random number. The requestRandomWords function requests a random number from the Chainlink VRF service. The fulfillRandomWords function (which MUST be present when inheriting from VRFConsumerBaseV2Plus) receives the random number and uses it to determine the winner. This ensures a fair and unpredictable lottery outcome.

Using Chainlink VRF introduces external dependencies and gas costs. It also requires setting up a Chainlink VRF subscription and managing the associated fees. However, these costs are often a worthwhile trade-off for enhanced security and fairness. 

Other alternatives include using a commit-reveal scheme. While commit-reveal schemes are more complex, they can be more cost-effective, but they require careful engineering to ensure proper security.

SOL-AM-MA-3: Is contract logic sensitive to transaction ordering?

  • Description: Miners control transaction ordering and can exploit this for front-running, back-running, or sandwich attacks.

  • Remediation: Implement protection by allowing users to specify acceptable results that revert transactions when breached.


Miners decide the order in which transactions are included in a block. While miners generally prioritize transactions with higher gas prices, they are not obligated to do so. This allowes malicious miners (or sophisticated bots) to strategically order transactions for their benefit. This exploitation can lead to front-running, back-running, or sandwich attacks. These attacks exploit the manipulation of transaction order to extract value.

Think about a decentralized exchange (DEX). A miner sees a large buy order for a specific token in the mempool. They could insert their own buy order before the large order (front-running), driving up the price. Then, they could insert their sell order after the large order (back-running or sandwich attack), profiting from the price increase caused by the initial trade.

Here's a simplified, vulnerable DEX:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;


import "@openzeppelin/contracts/token/ERC20/ERC20.sol";


// Simple ERC20 token for testing
contract TestToken is ERC20 {
    constructor(string memory name, string memory symbol, uint256 initialSupply) ERC20(name, symbol) {
        _mint(msg.sender, initialSupply);
    }
}


// Simplified DEX that's vulnerable to front-running
contract VulnerableDEX {
    TestToken public tokenA;
    TestToken public tokenB;
    uint public reserveA;
    uint public reserveB;


    constructor(address _tokenA, address _tokenB) {
        tokenA = TestToken(_tokenA);
        tokenB = TestToken(_tokenB);
    }


    // Initialize liquidity
    function addLiquidity(uint amountA, uint amountB) external {
        tokenA.transferFrom(msg.sender, address(this), amountA);
        tokenB.transferFrom(msg.sender, address(this), amountB);
        reserveA += amountA;
        reserveB += amountB;
    }


    // Calculate output amount for a given input
    function _calculateSwapOutput(address tokenIn, uint amountIn) internal view returns (uint amountOut) {
        require(tokenIn == address(tokenA) || tokenIn == address(tokenB), "Invalid token");


        bool isTokenA = tokenIn == address(tokenA);


        if (isTokenA) {
            amountOut = (reserveB * amountIn) / (reserveA + amountIn);
            require(amountOut < reserveB, "Insufficient liquidity");
        } else {
            amountOut = (reserveA * amountIn) / (reserveB + amountIn);
            require(amountOut < reserveA, "Insufficient liquidity");
        }


        return amountOut;
    }


    // Execute the swap with pre-calculated output
    function _executeSwap(address tokenIn, uint amountIn, uint amountOut, address sender) internal {
        bool isTokenA = tokenIn == address(tokenA);


        if (isTokenA) {
            tokenA.transferFrom(sender, address(this), amountIn);


            // Update reserves
            reserveA += amountIn;
            reserveB -= amountOut;


            // Transfer output tokens to the user
            tokenB.transfer(sender, amountOut);
        } else {
            tokenB.transferFrom(sender, address(this), amountIn);


            reserveB += amountIn;
            reserveA -= amountOut;


            tokenA.transfer(sender, amountOut);
        }
    }


    // Vulnerable swap function (no minimum output)
    function swap(address tokenIn, uint amountIn) external returns (uint amountOut) {
        // Calculate the expected output
        amountOut = _calculateSwapOutput(tokenIn, amountIn);


        // Execute the swap
        _executeSwap(tokenIn, amountIn, amountOut, msg.sender);


        return amountOut;
    }
}


In this VulnerableDEX contract, the swap function lacks slippage protection, making it vulnerable to sandwich attacks. These attacks are a form of Maximal Extractable Value (MEV) exploitation where profit is extracted by manipulating the order of transactions.

When a victim submits a swap transaction to the mempool, sophisticated MEV searchers identify the opportunity and execute a three-step attack:

  1. Frontrunning: The attacker first places a transaction purchasing the target asset, deliberately driving up the price.
  2. Victim transaction: The victim's swap executes at this artificially inflated price, receiving fewer tokens than expected due to the lack of minimum output guarantees.
  3. Backrunning: The attacker sells the previously acquired tokens at the higher price created by the victim's transaction, capturing the price spread as profit.


While any actor monitoring the mempool can perform sandwich attacks, block producers have privileged transaction ordering capabilities, allowing them to execute these attacks with greater certainty.

Below is a test that demonstrates the vulnerability:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;


import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";


contract TransactionOrderingTest is Test {
    VulnerableDEX dex;
    TestToken tokenA;
    TestToken tokenB;


    address victim = address(1);
    address attacker = address(2);
    address liquidityProvider = address(3);


    uint256 initialLiquidityA = 1000 ether;
    uint256 initialLiquidityB = 1000 ether;


    function setUp() public {
        // Deploy tokens with higher initial supply to support both tests
        tokenA = new TestToken("Token A", "TKNA", 100000 ether);
        tokenB = new TestToken("Token B", "TKNB", 100000 ether);


        // Deploy DEX
        dex = new VulnerableDEX(address(tokenA), address(tokenB));


        // Setup DEX with liquidity
        tokenA.transfer(liquidityProvider, 2000 ether);
        tokenB.transfer(liquidityProvider, 2000 ether);


        vm.startPrank(liquidityProvider);
        tokenA.approve(address(dex), initialLiquidityA);
        tokenB.approve(address(dex), initialLiquidityB);
        dex.addLiquidity(initialLiquidityA, initialLiquidityB);
        vm.stopPrank();


        // Give fresh tokens to victim and attacker for each test
        // We give them enough for both tests
        tokenA.transfer(victim, 20 ether);
        tokenA.transfer(attacker, 200 ether);
    }


    function testSandwichAttack() public {
        // Record initial balances
        uint attackerInitialBalanceA = tokenA.balanceOf(attacker);


        // Victim approves DEX to spend tokens
        vm.prank(victim);
        tokenA.approve(address(dex), 10 ether);


        // Attacker approves DEX to spend tokens
        vm.prank(attacker);
        tokenA.approve(address(dex), 100 ether);
        vm.prank(attacker);
        tokenB.approve(address(dex), type(uint256).max); // Allow selling tokens


        // STEP 1: Attacker front-runs by buying tokenB with a large amount of tokenA
        console.log("--- STEP 1: Attacker front-runs victim's trade ---");
        vm.prank(attacker);
        uint frontrunBought = dex.swap(address(tokenA), 100 ether);
        console.log("Attacker spent:", 100 ether, "tokenA");
        console.log("Attacker received:", frontrunBought, "tokenB");


        // Record pool state after front-run
        uint reserveAAfterFrontrun = dex.reserveA();
        uint reserveBAfterFrontrun = dex.reserveB();
        console.log("Pool state after front-run - Reserve A:", reserveAAfterFrontrun, "Reserve B:", reserveBAfterFrontrun);


        // STEP 2: Victim's transaction executes at a worse price
        console.log("\n--- STEP 2: Victim's trade executes at worse price ---");
        uint expectedOutputWithoutFrontrun = (initialLiquidityB * 10 ether) / (initialLiquidityA + 10 ether);


        vm.prank(victim);
        uint victimReceived = dex.swap(address(tokenA), 10 ether);
        console.log("Victim spent:", 10 ether, "tokenA");
        console.log("Victim expected to receive (without front-running):", expectedOutputWithoutFrontrun, "tokenB");
        console.log("Victim actually received:", victimReceived, "tokenB");
        console.log("Victim lost:", expectedOutputWithoutFrontrun - victimReceived, "tokenB due to front-running");


        // Record pool state after victim's trade
        uint reserveAAfterVictim = dex.reserveA();
        uint reserveBAfterVictim = dex.reserveB();
        console.log("Pool state after victim - Reserve A:", reserveAAfterVictim, "Reserve B:", reserveBAfterVictim);


        // STEP 3: Attacker back-runs by selling the tokenB they bought
        console.log("\n--- STEP 3: Attacker back-runs by selling tokenB ---");
        vm.prank(attacker);
        uint backrunReceived = dex.swap(address(tokenB), frontrunBought);
        console.log("Attacker sold:", frontrunBought, "tokenB");
        console.log("Attacker received:", backrunReceived, "tokenA");


        // Calculate attacker's profit in tokenA
        uint attackerFinalBalanceA = tokenA.balanceOf(attacker);
        int attackerProfit = int(attackerFinalBalanceA) - int(attackerInitialBalanceA);


        console.log("\n--- SANDWICH ATTACK SUMMARY ---");
        console.log("Attacker initial tokenA balance:", attackerInitialBalanceA);
        console.log("Attacker final tokenA balance:", attackerFinalBalanceA);
        console.log("Attacker's profit:", uint(attackerProfit), "tokenA");


        // Verify the profit is positive
        assertGt(attackerFinalBalanceA, attackerInitialBalanceA, "Attacker should profit from the sandwich attack");
    }
}


This test demonstrates a classic sandwich attack where an attacker strategically surrounds the victim's transaction. First, they front-run it with a purchase that artificially inflates the token price. Then, they execute the victim's swap at this manipulated price, resulting in severe slippage. Finally, the attacker back-runs with a sale that captures the profit created by the difference.

Mitigation
:

Mitigation strategies depend on the specific context, but in general, slippage protection prevents sandwich attacks by ensuring that the victim's transaction is not executed at an unfavorable price. Allow users to specify the maximum slippage they are willing to tolerate in a trade. If the price moves beyond this threshold, the transaction is reverted. This protects users from sandwich attacks.

Here's an example of slippage protection that mitigates the sandwich attack described in the above test.

// Secure swap function with minimum output requirement
    function swapWithMinimumOutput(
        address tokenIn,
        uint amountIn,
        uint minAmountOut
    ) external returns (uint amountOut) {
        // Calculate the expected output
        amountOut = _calculateSwapOutput(tokenIn, amountIn);


        // Check slippage before executing the swap
        require(amountOut >= minAmountOut, "Slippage too high");


        // Execute the swap
        _executeSwap(tokenIn, amountIn, amountOut, msg.sender);


        return amountOut;
    }


All examples are available on my GitHub here.

Conclusion

We've explored how miners, while essential to the blockchain's operation, can also influence smart contract execution, causing serious vulnerabilities. We've seen how relying on block.timestamp for time-sensitive operations, using block properties for randomness, and ignoring transaction ordering can open the door for miner manipulation.

Key takeaways:

  • block.timestamp is not a reliable time source. Use block.number or external oracles for critical timing operations.
  • Never use block properties for randomness. Opt for secure randomness sources like Chainlink VRF.
  • Be mindful of transaction ordering. Implement mitigation strategies like slippage protection.

Security is a continuous journey! Keep learning, keep experimenting, and keep challenging your assumptions. By understanding the attacker's mindset and applying the principles we've discussed today, you'll be well on your way to building more robust and secure smart contracts!

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.