Assessment reports>GTE>Critical findings>Attacker can manipulate the initial price in the Uniswap pool
Category: Coding Mistakes

Attacker can manipulate the initial price in the Uniswap pool

Critical Severity
Critical Impact
High Likelihood

Description

During the buy phase, if the full BONDING_SUPPLY amount of the launch tokens is sold, the remaining liquidity (both LaunchToken and USDC) related to this token are moved to the Uniswap pool using the router.addLiquidity function.

function buy(address token, address recipient, uint256 amountOutBase, uint256 worstAmountInQuote)
    external
    nonReentrant
    returns (uint256 amountOutBaseActual, uint256 amountInQuoteActual)
{
    [...]
    if (nextAmountSold >= BONDING_SUPPLY) {
        data.active = false;
    [...]
    }

    [...]

    if (!data.active) {
        uint256 tokensToLock = TOTAL_SUPPLY - BONDING_SUPPLY;

        token.safeApprove(address(router), tokensToLock);
        address(quoteAsset).safeApprove(address(router), data.quoteBoughtByCurve);

        // slither-disable-next-line unused-return
        router.addLiquidity(
            token, address(quoteAsset), tokensToLock, data.quoteBoughtByCurve, 0, 0, address(this), block.timestamp
        );
    [...]
    }
}

The addLiquidity function first checks if a pool already exists. If not, a new pool is created. Otherwise, this step is skipped.

Then, the function checks the current reserve amounts. If they are zero, the desired amounts of tokenA and tokenB are added to the pool as they are. Otherwise, the input amounts of tokens are adjusted based on the provided desired amounts.

First, the provided amountADesired is used. If the calculated amountBOptimal is less than or equal to amountBDesired, then amountBOptimal is used instead of amountBDesired. Otherwise, amountAOptimal is calculated using amountBDesired, and the resulting amount must be less than or equal to amountADesired.

The optimal amount is determined by the quote function of the UniswapV2Library, which uses the current reserve amounts reserveA and reserveB previously added to the pool. These values define the token ratio at which additional liquidity can be added. As a result, the reserveA and reserveB values dictate the current token price in the pool.

Consequently, the Launchpad contract will not provide the initially desired liquidity amounts (tokensToLock as LaunchToken and data.quoteBoughtByCurve as USDC). Instead, the liquidity provided will be either tokensToLock and amountBOptimal or amountAOptimal and data.quoteBoughtByCurve, depending on the current token ratio.

function _addLiquidity(
    [...]
) private returns (uint amountA, uint amountB) {
    // create the pair if it doesn't exist yet
    if (IUniswapV2Factory(factory).getPair(tokenA, tokenB) == address(0)) {
        IUniswapV2Factory(factory).createPair(tokenA, tokenB);
    }
    (uint reserveA, uint reserveB) = UniswapV2Library.getReserves(factory, tokenA, tokenB);
    if (reserveA == 0 && reserveB == 0) {
        (amountA, amountB) = (amountADesired, amountBDesired);
    } else {
        uint amountBOptimal = UniswapV2Library.quote(amountADesired, reserveA, reserveB);
        if (amountBOptimal <= amountBDesired) {
            require(amountBOptimal >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT');
            (amountA, amountB) = (amountADesired, amountBOptimal);
        } else {
            uint amountAOptimal = UniswapV2Library.quote(amountBDesired, reserveB, reserveA);
            assert(amountAOptimal <= amountADesired);
            require(amountAOptimal >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT');
            (amountA, amountB) = (amountAOptimal, amountBDesired);
        }
    }
}

To reach this state, reserveA and reserveB should be set to nonzero values, meaning liquidity must already be added to the pool before the Launchpad triggers the addLiquidity function.

The USDC token can be transferred to the pool without any restrictions. However, LaunchToken transfers are locked until the full BONDING_SUPPLY amount is sold. Despite this, tokens can still be transferred from the Launchpad contract during the execution of the buy function. As a result, a malicious user could set the Uniswap pool address as the recipient and provide the necessary liquidity to the pool, manipulating the token ratio to their advantage.

function _beforeTokenTransfer(address from, address to, uint256 /*amount*/ ) internal view override {
    if (!unlocked && from != launcher && to != launcher) {
        revert TransfersDisabledWhileBonding();
    }
}

Impact

An attacker can manipulate the initial liquidity setup of the Uniswap V2 pool for LaunchToken and, consequently, control the token price before the Launchpad contract executes addLiquidity.

By initializing a new Uniswap V2 pair for LaunchToken and USDC, the attacker can predefine the price by buying the required amount of LaunchToken and transferring it directly to the pool, setting the pool address as the recipient. Then, the attacker can add USDC liquidity at a desired price and call UniswapV2Pair.mint to lock in the reserves (reserveA and reserveB).

Once the full BONDING_SUPPLY is sold, the Launchpad contract calls addLiquidity. However, because the reserves have already been manipulated, the function will not add the full intended liquidity but will instead adjust the amounts to maintain the attacker's predefined price.

As a result, the attacker can immediately sell their LaunchToken, extracting more USDC than they would have under normal market conditions.

Recommendations

We recommend restricting the transfer of LaunchToken during the buy phase to the precalculated Uniswap pair address.

Remediation

This issue has been acknowledged by Liquid Labs, Inc., and fixes were implemented in the following commits:

Liquid Labs, Inc. provided the following response:

The first commit prevented donations by computing the pool address and preventing it from being the recipient field in launchpad buys (where you receive the launch token). It also added a stored pool address to try and prevent not just base token donations but donating quote to other pre bonding pools. However, we realized this is not necessary, as anyone with quote (like usdc) can just donate regardless.

The second commit removes the futile attempt to prevent single sided quote token donations

Zellic © 2025Back to top ↑