← Back to Blog

6 Critical AMM and DEX Security Vulnerabilities and Secure Fixes (2026)

2026-04-18 amm uniswap dex solidity security price manipulation sandwich attack 2026

In December 2020, Warp Finance lost $7.7 million. An attacker used flash loans to skew the reserves of Uniswap V2 pairs, artificially inflating the spot price of Uniswap LP tokens. Because the protocol calculated the collateral value of these LP tokens using spot reserves, the attacker was able to borrow far more than the collateral's true value, draining the protocol's vault.

Decentralized finance (DeFi) relies heavily on constant-product pricing models to facilitate permissionless token swaps. While the core liquidity pool architectures are robust, integrating smart contracts frequently implement insecure pricing logic and validation routines. Today, exploits rarely attack the core Uniswap contracts directly; instead, they target protocols that trust Uniswap's state outputs blindly.

This guide covers six critical vulnerability classes every DEX integration must address, with practical Solidity examples and secure fixes for each.


Vulnerability 1: Spot Price Oracle Manipulation

Vulnerability Analysis

Spot price oracle manipulation occurs when a smart contract calculates the price of an asset based on the immediate balance of tokens in a liquidity pool (such as calling getReserves()). Because these reserves can be drastically skewed within a single transaction using flash loans, spot prices are highly unsafe to trust for critical operations like lending collateral valuation or liquidations.

Vulnerable Code

Below is a vulnerable contract that queries getReserves() to calculate the token price. This can be manipulated atomically.

pragma solidity ^0.8.20;

interface IUniswapV2Pair {
    function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
}

contract VulnerableSpotOracle {
    // VULNERABLE: spot price used as oracle
    function getTokenPrice(address pair) public view returns (uint256) {
        (uint112 reserve0, uint112 reserve1,) = IUniswapV2Pair(pair).getReserves();
        // Price derived purely from current reserves — manipulable in one block
        return (uint256(reserve1) * 1e18) / uint256(reserve0);
    }
}

Exploit Scenario

  1. Flash Loan: The attacker borrows a massive amount of token0 from a flash loan provider (e.g., Aave or Uniswap flash swaps).
  2. Skew Pool: The attacker swaps token0 for token1 in the Uniswap pair, dramatically inflating reserve0 and depleting reserve1.
  3. Exploit Target: The target protocol calls getTokenPrice(), receiving a highly distorted price.
  4. Profit: The attacker borrows or mints assets against the target protocol using the artificially manipulated price as collateral.
  5. Arbitrage Back: The attacker swaps token1 back to token0, repays the flash loan, and pockets the difference.

Fixed Code

To prevent flash loan manipulation, protocols must use a Time-Weighted Average Price (TWAP) oracle, which averages the price over a specified time window (e.g., 24 hours). This requires the attacker to maintain price distortion across multiple blocks, making the attack economically unfeasible.

pragma solidity ^0.8.20;

interface IUniswapV2Pair {
    function token0() external view returns (address);
    function token1() external view returns (address);
    function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
    function price0CumulativeLast() external view returns (uint256);
    function price1CumulativeLast() external view returns (uint256);
}

library FixedPoint {
    struct uq112x112 {
        uint224 _x;
    }
    struct uq144x112 {
        uint256 _x;
    }

    // returns a uq112x112 which represents the ratio of the numerator to the denominator
    function fraction(uint112 numerator, uint112 denominator) internal pure returns (uq112x112 memory) {
        require(denominator > 0, "FixedPoint: DIV_BY_ZERO");
        return uq112x112(uint224((uint256(numerator) << 112) / denominator));
    }

    // multiplication without shifting to keep precision until final decode
    function mul(uq112x112 memory self, uint val) internal pure returns (uq144x112 memory) {
        return uq144x112(uint256(self._x) * val);
    }

    // final step of decoding shifts right by 112 bits exactly once
    function decode144(uq144x112 memory self) internal pure returns (uint144) {
        return uint144(self._x >> 112);
    }
}

contract UniswapV2OracleSimple {
    using FixedPoint for *;

    uint public constant PERIOD = 24 hours;

    IUniswapV2Pair public immutable pair;
    address public immutable token0;
    address public immutable token1;

    uint    public price0CumulativeLast;
    uint    public price1CumulativeLast;
    uint32  public blockTimestampLast;

    FixedPoint.uq112x112 public price0Average;
    FixedPoint.uq112x112 public price1Average;

    constructor(address _pair) {
        pair = IUniswapV2Pair(_pair);
        token0 = pair.token0();
        token1 = pair.token1();
        price0CumulativeLast = pair.price0CumulativeLast();
        price1CumulativeLast = pair.price1CumulativeLast();
        (, , blockTimestampLast) = pair.getReserves();
    }

    // Helper function to get current block timestamp modulo 2**32
    function currentBlockTimestamp() internal view returns (uint32) {
        return uint32(block.timestamp % 2**32);
    }

    // Calculates current cumulative prices on-the-fly to ensure price updates are accurate even if no swap occurred
    function currentCumulativePrices(
        address _pair
    ) internal view returns (uint price0Cumulative, uint price1Cumulative, uint32 blockTimestamp) {
        blockTimestamp = currentBlockTimestamp();
        price0Cumulative = IUniswapV2Pair(_pair).price0CumulativeLast();
        price1Cumulative = IUniswapV2Pair(_pair).price1CumulativeLast();

        (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLastOracle) = IUniswapV2Pair(_pair).getReserves();
        if (blockTimestampLastOracle != blockTimestamp) {
            uint32 timeElapsed;
            unchecked {
                timeElapsed = blockTimestamp - blockTimestampLastOracle;
            }
            // Addition overflow is intended behavior for modular arithmetic
            unchecked {
                price0Cumulative += uint256(FixedPoint.fraction(reserve1, reserve0)._x) * timeElapsed;
                price1Cumulative += uint256(FixedPoint.fraction(reserve0, reserve1)._x) * timeElapsed;
            }
        }
    }

    function update() external {
        (uint price0Cumulative, uint price1Cumulative, uint32 blockTimestamp) = currentCumulativePrices(address(pair));
        uint32 timeElapsed;
        unchecked {
            timeElapsed = blockTimestamp - blockTimestampLast;
        }
        require(timeElapsed >= PERIOD, "UniswapOracle: PERIOD_NOT_ELAPSED");

        // Arithmetic overflow/underflow is desired behavior for cumulative time-weighted calculation
        unchecked {
            price0Average = FixedPoint.uq112x112(
                uint224((price0Cumulative - price0CumulativeLast) / timeElapsed)
            );
            price1Average = FixedPoint.uq112x112(
                uint224((price1Cumulative - price1CumulativeLast) / timeElapsed)
            );
        }

        price0CumulativeLast = price0Cumulative;
        price1CumulativeLast = price1Cumulative;
        blockTimestampLast = blockTimestamp;
    }

    // SECURE: returns 24h TWAP, not manipulable via flash loan
    function consult(address token, uint amountIn) external view returns (uint amountOut) {
        if (token == token0) {
            // Assign storage state variable to local memory variable to match library signature
            FixedPoint.uq112x112 memory price0Avg = price0Average;
            amountOut = price0Avg.mul(amountIn).decode144();
        } else {
            require(token == token1, "UniswapOracle: INVALID_TOKEN");
            // Assign storage state variable to local memory variable to match library signature
            FixedPoint.uq112x112 memory price1Avg = price1Average;
            amountOut = price1Avg.mul(amountIn).decode144();
        }
    }
}

Vulnerability 2: Sandwich Attack

Vulnerability Analysis

A sandwich attack is a form of front-running where an attacker spots a user's pending transaction in the public mempool and places transactions both before (front-run) and after (back-run) the user's trade. This manipulates the asset price, forcing the user to execute at their maximum slippage, while the attacker captures the spread as profit.

To defend against this, the swapper must enforce a strict amountOutMin value. Crucially, calculating amountOutMin inside the smart contract using getAmountsOut() does not protect against sandwich attacks. If an attacker front-runs the trade, the pool reserves will have already changed when the user's transaction executes; a dynamic getAmountsOut() check would simply return the already-manipulated price, letting the transaction proceed.

Furthermore, passing the AMM router address as an arbitrary call parameter (address router) introduces an arbitrary address approval vulnerability. If an attacker passes a malicious contract address as the router, the swapper contract will blindly approve the user's tokens to the exploit contract, leading to immediate fund drainage.

Vulnerable Code

This code queries getAmountsOut() on-chain to determine amountOutMin, rendering the slippage protection completely ineffective. Additionally, the router address is passed dynamically as a parameter, making the token approval routine unsafe.

pragma solidity ^0.8.20;

interface IERC20 {
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
    function approve(address spender, uint256 amount) external returns (bool);
}

interface IUniswapV2Router02 {
    function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts);
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);
}

contract VulnerableSlippageSwapper {
    // VULNERABLE: Allows any arbitrary address to be passed as router and approved
    function swapWithVulnerableSlippage(
        address router,
        uint256 amountIn,
        address[] calldata path,
        uint256 slippageBps
    ) external returns (uint256[] memory amounts) {
        // Retrieve tokens from user and approve arbitrary Router address
        IERC20(path[0]).transferFrom(msg.sender, address(this), amountIn);
        IERC20(path[0]).approve(router, amountIn);

        // Querying on-chain is vulnerable to front-run reserve manipulation
        uint256[] memory expectedAmounts = IUniswapV2Router02(router).getAmountsOut(amountIn, path);
        uint256 expectedOut = expectedAmounts[expectedAmounts.length - 1];

        // This checks slippage against the already manipulated pool state!
        uint256 amountOutMin = expectedOut * (10000 - slippageBps) / 10000;

        amounts = IUniswapV2Router02(router).swapExactTokensForTokens(
            amountIn,
            amountOutMin,
            path,
            msg.sender,
            block.timestamp
        );
    }
}

Fixed Code

Slippage tolerance must be calculated off-chain prior to submitting the transaction. The transaction signer calculates the minimum acceptable output based on trusted oracle prices or off-chain data and passes amountOutMin directly as a parameter. Additionally, the trusted router address must be set as an immutable state variable in the constructor to eliminate arbitrary approval risks.

pragma solidity ^0.8.20;

interface IERC20 {
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
    function approve(address spender, uint256 amount) external returns (bool);
}

interface IUniswapV2Router02 {
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);
}

contract SecureSlippageSwapper {
    // SECURE: Router address is set at constructor deployment and is immutable
    address public immutable router;

    constructor(address _router) {
        require(_router != address(0), "Invalid router address");
        router = _router;
    }

    // SECURE: Enforces amountOutMin calculated off-chain and passed as a parameter
    function swapWithSlippageProtection(
        uint256 amountIn,
        uint256 amountOutMin,
        uint256 deadline,
        address[] calldata path
    ) external returns (uint256[] memory amounts) {
        IERC20(path[0]).transferFrom(msg.sender, address(this), amountIn);
        IERC20(path[0]).approve(router, amountIn);

        amounts = IUniswapV2Router02(router).swapExactTokensForTokens(
            amountIn,
            amountOutMin, // Enforced externally, immune to immediate pool manipulation
            path,
            msg.sender,
            deadline      // Deadline is also supplied externally
        );
    }
}

Vulnerability 3: Reentrancy via Token Callbacks

Vulnerability Analysis

Certain standard tokens allow receivers to receive notification callbacks during a transfer (such as tokensToSend and tokensReceived in the legacy ERC-777 standard). In a DEX context, if a pool contract executes transfers before updating its internal reserves, a malicious receiver can hook into the callback and re-enter the pool contract. This allows them to perform another swap using outdated reserve balances before the initial transaction settles.

Although the ERC-777 standard was deprecated and completely removed from OpenZeppelin Contracts v5, this does not eliminate the risk for existing pools. Many legacy ERC-777 tokens remain active and widely traded on Ethereum mainnet. If these tokens are introduced into a liquidity pool without proper safeguards, they pose a live and severe reentrancy threat. Therefore, pool contracts must enforce a robust reentrancy guard to protect their state, regardless of whether modern token standards are used.

Vulnerable Code

Below is a simplified swap execution that is vulnerable to callback reentrancy because external transfers are executed before the internal reserve values are updated.

pragma solidity ^0.8.20;

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
}

interface IUniswapV2Callee {
    function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external;
}

contract VulnerablePool {
    uint112 private reserve0;
    uint112 private reserve1;
    address public token0;
    address public token1;

    // VULNERABLE: Optimistic transfer executes BEFORE reserve updates
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external {
        if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
        if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);

        // Callback allows execution context transfer to the recipient
        if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);

        // Reserve update occurs after external interaction
        uint balance0 = IERC20(token0).balanceOf(address(this));
        uint balance1 = IERC20(token1).balanceOf(address(this));

        _update(balance0, balance1); 
    }

    function _safeTransfer(address token, address to, uint amount) private {
        (bool success, ) = token.call(abi.encodeWithSignature("transfer(address,uint256)", to, amount));
        require(success, "Transfer failed");
    }

    function _update(uint balance0, uint balance1) private {
        reserve0 = uint112(balance0);
        reserve1 = uint112(balance1);
    }
}

Fixed Code

Adding a reentrancy guard (lock modifier) restricts any subsequent execution of state-changing functions on the contract while a function is active.

pragma solidity ^0.8.20;

contract SecurePool {
    uint private unlocked = 1;

    // SECURE: Reentrancy guard preventing any nested calls
    modifier lock() {
        require(unlocked == 1, "UniswapV2: LOCKED");
        unlocked = 0;
        _;
        unlocked = 1;
    }

    // Applied to all state-changing functions
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
        // Safe swap implementation, protected by guard
    }
}

Vulnerability 4: K-Value Manipulation

Vulnerability Analysis

The constant product invariant ($x \times y = k$) must hold true (or increase by the fee fraction) after any swap. In custom AMM implementations, a common vulnerability is checking the invariant using integer division, which truncates remainders and allows attackers to execute multiple micro-swaps that incrementally reduce the pool's K value.

Another severe threat occurs when the invariant check depends on the caller-supplied input arguments rather than verifying actual token balances. If the contract calculates fee deduction using transaction parameters instead of internal state differences, an attacker can send tokens to the pool but pass zeros as the input arguments, bypassing the fee validation completely.

Additionally, failing to verify the return value of ERC-20 transfer calls introduces a critical security gap. For non-standard ERC-20 tokens that return false on failure instead of reverting, the pool contract might assume a transfer was successful when it actually failed, corrupting the AMM's reserve tracking.

Finally, an AMM contract that is deployed with initial reserves (reserve0, reserve1) set to 0 but lacks a liquidity provision (mint) mechanism will always revert when a swap is attempted, as the output constraint amountOut < reserve cannot be satisfied. Therefore, a production-grade custom AMM must provide a constructor to initialize token addresses, a reentrancy guard on all state-changing routines, and a dedicated mint function to seed initial liquidity.

Vulnerable Code

Below is a vulnerable custom AMM that checks the K-invariant using truncating division, lacks a reentrancy guard, does not verify ERC-20 transfer results, and does not provide a constructor or mint mechanism to initialize reserves.

pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 value) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

contract VulnerableCustomAMM {
    address public token0;
    address public token1;
    uint256 public reserve0;
    uint256 public reserve1;

    constructor(address _token0, address _token1) {
        token0 = _token0;
        token1 = _token1;
    }

    // VULNERABLE: Updates reserves based on differences, but lacks reentrancy guards
    function mint() external returns (uint256) {
        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));
        uint256 amount0 = balance0 - reserve0;
        uint256 amount1 = balance1 - reserve1;
        require(amount0 > 0 && amount1 > 0, "Insufficient liquidity minted");

        reserve0 = balance0;
        reserve1 = balance1;
        return amount0;
    }

    // VULNERABLE: Checks K-invariant using division, causing truncation that can be exploited to drain the pool.
    // Lacks reentrancy guard, allowing callback-based reentrancy if ERC-777 is used.
    // Does not check return values of transfer calls.
    function swap(
        uint256 amount0Out,
        uint256 amount1Out
    ) external {
        require(amount0Out > 0 || amount1Out > 0, "Zero output");

        if (amount0Out > 0) IERC20(token0).transfer(msg.sender, amount0Out);
        if (amount1Out > 0) IERC20(token1).transfer(msg.sender, amount1Out);

        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));

        // Truncating division allows the constant product invariant to be bypassed
        uint256 kOld = (reserve0 * reserve1) / 1e18;
        uint256 kNew = (balance0 * balance1) / 1e18;
        require(kNew >= kOld, "K invariant violated");

        reserve0 = balance0;
        reserve1 = balance1;
    }
}

Fixed Code

To prevent parameter manipulation and fee bypasses, the contract calculates actual input amounts internally by comparing the contract's current balance, existing reserves, and the outputs sent out. We enforce exact product verification using scaled integers without division. To ensure non-standard tokens do not silently fail, we explicitly validate the return value of every transfer call. Lastly, we apply a reentrancy guard (lock modifier) to both the mint and swap functions to block token transfer callback reentrancy vectors.

pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 value) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

contract SecureCustomAMM {
    address public token0;
    address public token1;
    uint256 public reserve0;
    uint256 public reserve1;

    uint256 private unlocked = 1;

    // SECURE: Reentrancy guard modifier preventing nested callback attempts
    modifier lock() {
        require(unlocked == 1, "AMM: LOCKED");
        unlocked = 0;
        _;
        unlocked = 1;
    }

    constructor(address _token0, address _token1) {
        require(_token0 != address(0) && _token1 != address(0), "Invalid token addresses");
        token0 = _token0;
        token1 = _token1;
    }

    // SECURE: Safe liquidity provision method to initialize and update reserves
    function mint() external lock returns (uint256) {
        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));
        uint256 amount0 = balance0 - reserve0;
        uint256 amount1 = balance1 - reserve1;
        require(amount0 > 0 && amount1 > 0, "Insufficient liquidity minted");

        reserve0 = balance0;
        reserve1 = balance1;
        return amount0;
    }

    // SECURE: Enforces exact multiplication-based K-invariant validation, adjusting for fees without division.
    // Inputs are tracked internally based on actual balances to prevent parameter trust vulnerabilities.
    // Explicitly verifies token transfer return values to protect against non-standard ERC-20 failures.
    // Reentrancy guard prevents external transfer callbacks from hijacking execution mid-swap.
    function swap(
        uint256 amount0Out,
        uint256 amount1Out
    ) external lock {
        require(amount0Out > 0 || amount1Out > 0, "Zero output");
        require(amount0Out < reserve0 && amount1Out < reserve1, "Insufficient liquidity");

        if (amount0Out > 0) {
            require(IERC20(token0).transfer(msg.sender, amount0Out), "Transfer failed");
        }
        if (amount1Out > 0) {
            require(IERC20(token1).transfer(msg.sender, amount1Out), "Transfer failed");
        }

        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));

        // Calculate actual deposited amount internally based on balance changes
        uint256 amount0In = balance0 > reserve0 - amount0Out ? balance0 - (reserve0 - amount0Out) : 0;
        uint256 amount1In = balance1 > reserve1 - amount1Out ? balance1 - (reserve1 - amount1Out) : 0;
        require(amount0In > 0 || amount1In > 0, "Insufficient input amount");

        // Deduct 0.3% fee from input amounts before checking constant product invariant
        uint256 balance0Adjusted = (balance0 * 1000) - (amount0In * 3);
        uint256 balance1Adjusted = (balance1 * 1000) - (amount1In * 3);

        // Avoid any division to eliminate rounding/truncation bugs
        require(
            balance0Adjusted * balance1Adjusted >= reserve0 * reserve1 * (1000**2),
            "K invariant violated"
        );

        reserve0 = balance0;
        reserve1 = balance1;
    }
}

Vulnerability 5: Deadline Exploitation

Vulnerability Analysis

A swap's deadline parameter prevents transactions from executing after market conditions have shifted significantly. When a transaction is submitted to the mempool with a dynamic deadline calculated inside the smart contract (such as block.timestamp + 300 or type(uint256).max), the check is rendered useless.

Because block.timestamp is evaluated when the block is mined rather than when the user signed the transaction, a miner or validator can hold the transaction in the mempool for hours or days. Once the market moves in the attacker's favor, the transaction is executed. Because the contract dynamically evaluates the deadline at the moment of execution, it will always pass (since execution_timestamp + 300 > execution_timestamp).

Furthermore, in both the vulnerable and secure instances, the transaction must retrieve tokens from the user using transferFrom and grant a spending approve allowance to the Router before invoking the swap. Otherwise, the Router will fail to pull the tokens and the call will revert immediately.

Vulnerable Code

Below are two vulnerable approaches: hardcoding the maximum possible value, and dynamically calculating the deadline in the contract. Additionally, the router address is supplied dynamically as a parameter, making the token approval routine unsafe.

pragma solidity ^0.8.20;

interface IERC20 {
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
    function approve(address spender, uint256 amount) external returns (bool);
}

interface IUniswapV2Router02 {
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);
}

contract VulnerableDeadlineSwapper {
    IUniswapV2Router02 public immutable router;

    constructor(address _router) {
        router = IUniswapV2Router02(_router);
    }

    // VULNERABLE: Hardcoded maximum deadline and arbitrary router approval
    function swapUnlimitedDeadline(
        address dynamicRouter,
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path
    ) external {
        IERC20(path[0]).transferFrom(msg.sender, address(this), amountIn);
        IERC20(path[0]).approve(dynamicRouter, amountIn);

        IUniswapV2Router02(dynamicRouter).swapExactTokensForTokens(
            amountIn,
            amountOutMin,
            path,
            msg.sender,
            type(uint256).max // No expiration protection
        );
    }

    // VULNERABLE: Dynamic calculation inside the contract and arbitrary router approval
    function swapDynamicDeadline(
        address dynamicRouter,
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path
    ) external {
        IERC20(path[0]).transferFrom(msg.sender, address(this), amountIn);
        IERC20(path[0]).approve(dynamicRouter, amountIn);

        IUniswapV2Router02(dynamicRouter).swapExactTokensForTokens(
            amountIn,
            amountOutMin,
            path,
            msg.sender,
            block.timestamp + 300 // Evaluates to 300 seconds from execution time, not submission time
        );
    }
}

Fixed Code

The deadline must be calculated off-chain at the time of transaction signing (e.g., current_epoch_time + 120 seconds) and passed as an absolute timestamp parameter. If the transaction sits in the mempool past this absolute timestamp, the router will reject the swap. The router is set as an immutable state variable to prevent arbitrary target injection.

pragma solidity ^0.8.20;

interface IERC20 {
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
    function approve(address spender, uint256 amount) external returns (bool);
}

interface IUniswapV2Router02 {
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);
}

contract SecureDeadlineSwapper {
    // SECURE: Router address is set at constructor deployment and is immutable
    address public immutable router;

    constructor(address _router) {
        require(_router != address(0), "Invalid router address");
        router = _router;
    }

    // SECURE: Absolute deadline is calculated off-chain and passed in as a parameter
    function swapWithDeadline(
        uint256 amountIn,
        uint256 amountOutMin,
        uint256 deadline, // Absolute unix timestamp calculated at transaction creation
        address[] calldata path,
        address recipient
    ) external returns (uint256[] memory amounts) {
        IERC20(path[0]).transferFrom(msg.sender, address(this), amountIn);
        IERC20(path[0]).approve(router, amountIn);

        amounts = IUniswapV2Router02(router).swapExactTokensForTokens(
            amountIn,
            amountOutMin,
            path,
            recipient,
            deadline // Router reverts if block.timestamp > deadline
        );
    }
}

Vulnerability 6: Using getAmountsOut() as a Price Oracle

Vulnerability Analysis

getAmountsOut() is a router function that queries current reserve balances to estimate token output. Because it directly relies on spot reserves, using it to make protocol-level pricing or business logic decisions (like collateral evaluations or rebalancing thresholds) introduces the exact same spot-price manipulation vulnerabilities as reading getReserves() directly.

Vulnerable Code

A vulnerable rebalancer that uses getAmountsOut() to determine if a vault needs to rebalance:

pragma solidity ^0.8.20;

interface IUniswapV2Router02 {
    function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts);
}

contract VulnerableRebalancer {
    address public router;
    uint256 public targetPrice;

    // VULNERABLE: Decisions rely on spot reserve calculations via getAmountsOut()
    function shouldRebalance(address tokenA, address tokenB) external view returns (bool) {
        address[] memory path = new address[](2);
        path[0] = tokenA;
        path[1] = tokenB;

        // Manipulable in one transaction via a flash loan
        uint256[] memory amounts = IUniswapV2Router02(router).getAmountsOut(1e18, path);
        uint256 currentPrice = amounts[1];

        return currentPrice > targetPrice;
    }
}

Fixed Code

Decisions must be based on a time-weighted average price (TWAP) or a decentralized oracle feed (like Chainlink).

pragma solidity ^0.8.20;

interface IUniswapV2Oracle {
    function consult(address token, uint256 amountIn) external view returns (uint256);
}

contract SecureRebalancer {
    IUniswapV2Oracle public immutable oracle;
    uint256 public targetPrice;

    constructor(address _oracle) {
        oracle = IUniswapV2Oracle(_oracle);
    }

    // SECURE: Rebalancing decisions are guided by a TWAP Oracle
    function shouldRebalance(address tokenA) external view returns (bool) {
        uint256 twapPrice = oracle.consult(tokenA, 1e18);
        return twapPrice > targetPrice;
    }
}

What ContractScan Detects

Vulnerability Detection Method
Spot price from getReserves() used as oracle Static analysis: flags reserve0/reserve1 ratio in pricing functions
amountOutMin = 0 in swap calls AST pattern match on router call arguments
Missing reentrancy guard on swap/mint/burn Control flow analysis: identifies external calls before state updates
K-invariant not checked in custom pools Data flow analysis: verifies post-swap invariant assertion
Unverified ERC-20 transfer and transferFrom return values Static analysis: checks if boolean return status is validated
Dynamic deadline calculation or type(uint256).max AST detection of internal block.timestamp + offset and max uint deadline assignments
getAmountsOut() used in protocol decision logic Call graph analysis: traces output of getAmountsOut into conditional branches
ERC-777 token in pool without reentrancy guard Token interface detection combined with reentrancy path analysis (Note: Despite removal in OpenZeppelin v5, active legacy ERC-777 tokens on mainnet still present reentrancy risks)
Arbitrary Router address approval Verifies if token approvals are sent to dynamic function parameters instead of immutable or whitelisted addresses
Missing initial liquidity provision Checks if custom AMMs initialize reserves via a constructor and support a locked mint function

Secure AMM Integration Checklist


FAQ

What is the difference between getAmountsOut() and a TWAP oracle?

getAmountsOut() queries the spot reserves of the Uniswap pair at the current block execution moment, making it highly manipulable through flash loans. A TWAP (Time-Weighted Average Price) oracle accumulates reserves and prices over multiple blocks, taking time into account to prevent single-transaction manipulation.

Why is ignoring the return value of ERC-20 transfer and transferFrom risky?

Some non-standard ERC-20 tokens do not revert on failure; instead, they return false. If a contract ignores this return value, it will execute as if the transfer succeeded, leading to internal accounting errors or loss of funds. Contracts must validate these return values using require checks or OpenZeppelin's SafeERC20 wrapper library.

How does an ERC-777 token cause reentrancy in a Uniswap V2 pair?

ERC-777 implements standard callbacks (tokensToSend / tokensReceived) that notify senders and receivers before and after balance transfers. When a pool transfers tokens to an address during a swap before updating its internal reserves, the recipient's callback executes. This gives control back to a malicious contract, allowing it to call swap() again using outdated reserve rates. While modern libraries like OpenZeppelin v5 have removed ERC-777 implementations to discourage its use, many legacy ERC-777 tokens remain active on Ethereum mainnet, making a reentrancy guard (lock modifier) a mandatory requirement for custom AMM pools.

Why does block.timestamp + offset fail to protect against deadline exploits?

If the deadline is computed inside the smart contract during execution, it calculates the deadline relative to when the block is mined rather than when the transaction was signed. A validator can keep the transaction in the mempool for a long time and execute it when conditions are favorable, rendering the deadline check useless.

How can I prevent sandwich attacks in my smart contract integration?

Calculate the minimum output amount (amountOutMin) off-chain based on your slippage tolerance (e.g., 0.5%) and pass it directly into the execution transaction payload. Never query dynamic spot outputs on-chain to determine this threshold.


Audit Your DEX Integration

Securing AMM integrations requires auditing the interactive boundaries between your custom protocols and decentralized exchanges. Static analysis tools effectively flag deterministic patterns like unconstrained slippage parameters, missing mutex locks, and dynamic deadline constraints. However, evaluating complex economic threat vectors and multi-block oracle skewing requires dedicated simulation testing.

Audit your DEX integration with ContractScan.



Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →