Attacker can manipulate the initial price in the Uniswap pool
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