GTE Perps and Launchpad
Findings & Analysis Report
2026-02-05
Table of contents
- Summary
- Scope
- Severity Criteria
- GTE Perps
-
- [M-01] Due to logical error when changing leverage the system also refunds the margin that should be backing the unrealized loss the account holds.
- [M-02] The protocol doesn’t check whether the orders have expired when the mid and impact price are calculated
- [M-03] EMA-based Mark Price Component Exhibits Excessive Lag Leading to Inaccurate Pricing
- [M-04] Leverage Increase Breaks Margin Removal Invariant
- [M-05] Protocol Disable Functionality Bypass Allows Critical Operations During Emergency Shutdown
- [M-06] Liquidation stalls when top-of-book is outside divergence band (STANDARD & BACKSTOP), allowing under-margined positions to persist
- [M-07] Partial Fills before
amendOrderTX exposes Users to unintended Risks - [M-08] Reduce-only orders can be used to inflate
quoteOIand DoS the orderbook - [M-09] GTL contract is vulnerable to an inflation attack
- [M-10] Incorrect Removal Of Asset In UpdateAccount Due To Wrong Implementation in
_movePop - [M-11] Funding Mis-Accrual Due to Missing Time Normalization in
_calcFundingIndex - [M-12] Perps - GTL post-only orders skew totalAssets accounting, minting excessive shares and rendering the GTL vault insolvent
- [M-13] Max Leverage Reduction Not Enforced for Legacy Positions
- [M-14] Margin Balance can be forced to be < 0 even realizing upnl indirectly
- [M-15] Loss for protocol by incorrectly assuming the position has been fully closed
- GTE Launchpad
-
- [H-03]
GTELaunchpadV2Pair::burnover-estimates distribution amounts when there are non-zero accrued launchpad fees - [H-04] Attacker can drain funds from
GTELaunchPadV2Pairusingswap - [H-05]
GTELaunchpadV2Pairpermits minting LP tokens for free when there are non-zero accumulated launch pad fees - [H-06] Donations to
Distributorwith arbitraryquoteTokencan be used to drain all quote rewards from distributor - [H-07] Total reward shares for token can reach zero after unlocking, causing
GTELaunchpadV2Pairto be bricked - [H-08] CREATE2 address of the uniswap pair used by
LaunchPaddoes not match address of pair deployed byGTELaunchpadV2PairFactory - [H-09] DOS of Launchpad Graduation via
addLiquiditywith 1 Wei donation - [H-10] Protocol fails to charge fees from swap amount
- [H-03]
-
- [M-16] Accumulated rewards per share can round to zero
- [M-17]
LaunchTokentransfers cause staking rewards to be lost to theLaunchPad - [M-18] Pair pre-creation disables Launchpad rewards hooks leading to no fees accrued or distributed
- [M-19] Unsynchronized LaunchpadLPVault address leading to Loss of fee to Stakers of new launchPad token
- [M-20] Price Accumulators Overflow in GTELaunchpadV2Pair contract Causes AMM-wide DoS
- [M-21] Bypass of recipient check allows pre-seeding the real pair and manipulating initial AMM price
- [M-23] Rounding down in Quote calculation allows underpriced LaunchToken purchases by Malicious user, compounding protocol loss over multiple buys.
- [M-24] Launchpad slippage is not enforced properly during token graduation
- [M-25] Bonding Shares Incorrectly Reduced/unstaked on Transfer in launchToken.
-
Low Risk and Informational Issues
- L-01 Staker Rewards Misdirected to Launchpad Contract
- L-02 Incorrect Pair Address Derivation Breaks Recipient Validation
- L-03 Potential DoS from Integer Overflow in Price Cumulative Calculations
- L-04 Missing Deadline Protection in Buy Function
- L-05 No Slippage Protection During Graduation
- L-06 Hardcoded Deadline in AMM Operations
- Additional Observations
- Disclosures
Overview
About C4
Code4rena (C4) is a competitive audit platform where security researchers, referred to as Wardens, review, audit, and analyze codebases for security vulnerabilities in exchange for bounties provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the GTE Perps and Launchpad smart contract system. The audit took place from August 28 to September 25, 2025.
Final report assembled by Code4rena.
Summary
The C4 analysis yielded an aggregated total of 35 unique vulnerabilities. Of these vulnerabilities, 10 received a risk rating in the category of HIGH severity and 25 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 28 QA reports compiling issues with a risk rating of LOW severity or informational.
All of the issues presented here are linked back to their original finding, which may include relevant context from the judge and GTE team.
Considering the number of issues identified, it is statistically likely that there are more complex bugs still present that could not be identified given the time-boxed nature of this engagement. It is recommended that a follow-up audit and development of a more complex stateful test suite be undertaken prior to continuing to deploy significant monetary capital to production.
Scope
The code under review can be found within the C4 GTE Perps and Launchpad repository, and is composed of 34 smart contracts written in the Solidity programming language and includes 5,260 lines of Solidity code.
The code in C4’s GTE Perps and Launchpad repository was pulled from:
- Repository: https://github.com/liquid-labs-inc/gte-contracts
- Commit hash:
775adb9c6954e3ab9075c6caccbcad883e198bb1
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/informational.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
GTE Perps
This section of the report includes findings associated with the GTE Perps contracts.
High Risk Findings (2)
[H-01] Risk of Gas DoS due to Looping
Submitted by lanyi2023, also found by 0xGutzzz, 4ny0n3, Centaur, codegpt, codertjay, dhank, dreamcoder, fullstop, harry, hgrano, HighKingMargo, jerry0422, KonstantinVelev, kwad, meshaqRapha, mohitismmortal, newspacexyz, Pelz, player, qpzm, queen, r1ver, rbd3, romans, saraswati, SolidityScan, Stormy, TheWeb3Mechanic, typicalHuman, udogodwin, and Wolffey
perps/GTL.sol #L74
Both the cancelWithdrawal and processWithdrawals functions iterate over the _withdrawalQueue. Therefore, if an attacker were to maliciously submit a large number of minor withdrawal requests, any subsequent call to cancelWithdrawal or processWithdrawals would hit the gas limit, leading to a Denial of Service (DoS) attack on the protocol.
Recommended mitigation steps
Iterating through the entire queue to find a specific withdrawal request to cancel is inefficient and dangerous. Users should instead be required to provide a unique identifier for the request they wish to cancel. Using a mapping is the most gas-efficient way to implement this.
View detailed Proof of Concept
[H-02] Backstop bid-side frozen by tick-size constraint
Submitted by 0xPhantom, also found by 0xShitgem, dimulski, SeveritySquad, and taticuvostru
Backstop orders are restricted to maker-only:
// contracts/perps/types/Market.sol:141
if (bookType == BookType.BACKSTOP && args.tif != TiF.MOC) revert InvalidBackstopOrder();
Maker orders (TiF.MOC) revert whenever they would immediately match:
// contracts/perps/types/CLOBLib.sol:246
if (ds.getBestAsk() <= newOrder.price) {
if (tif == TiF.MOC) revert PostOnlyOrderWouldBeFilled();
}
Backstop prices must respect the book tick size:
// contracts/perps/types/Book.sol:86
if (price % StorageLib.loadBookSettings(self.config.asset).tickSize != 0) revert OrderPriceOutOfBounds();
Attack: The Buy side of the backstop orderbook an attacker place a SELL backstop with price = tickSize. That becomes getBestAsk(). Any future BUY backstop must choose a price >= tickSize; otherwise it’s below the tick step.
The first legal price equals the best ask, so _executeBuyOrder always detects crossing and reverts with PostOnlyOrderWouldBeFilled. Bids cannot be added until the attacker cancels or the order fills, effectively DoSing the bid side of the backstop book.
If no backstop bids exist, liquidations cannot rely on the backstop.
Recommended mitigation steps
Add a one-tick buffer for post-only backstop orders.
View detailed Proof of Concept
Medium Risk Findings (15)
[M-01] Due to logical error when changing leverage the system also refunds the margin that should be backing the unrealized loss the account holds.
Submitted by Stormy, also found by dhank, lonelybones, and Mike_Bello90
On short explanation, when changing the leverage of already opened position, the system calculates the new intended margin based on the current position notional size and the changed leverage value. The intended margin is then checked against the position’s total equity (margin + upnl) to ensure that the position remains above the minimum open margin value.
cache.fundingPayment = ClearingHouseLib.realizeFundingPayment(cache.assets, cache.positions);
cache.newMargin = clearingHouse.getIntendedMargin(cache.assets, cache.positions);
// assert open margin requirement met
clearingHouse.assertOpenMarginRequired({
assets: cache.assets,
positions: cache.positions,
margin: cache.newMargin.toInt256()
});
clearingHouse.setPositions({
tradedAsset: "",
account: account,
subaccount: subaccount,
assets: cache.assets,
positions: cache.positions
});
// settle delta between new and prev margin & new and prev orderbook collateral
collateralDelta = StorageLib.loadCollateralManager().settleNewLeverage({
account: account,
subaccount: subaccount,
collateralDeltaFromBook: cache.collateralDeltaFromBook,
newMargin: cache.newMargin.toInt256(),
fundingPayment: cache.fundingPayment
});
The function settleNewLeverage is supposed to correctly account the margin balance of the user when leverage is changed. However the function does not take into account the unrealized PnL when calculating the new margin balance. As a result, the system can refund all excess margin including the margin that is effectively used to cover ongoing unrealized losses the account holds.
Simple example with Bob:
- Bob has 20k long position at 1x leverage.
- The market moves against Bob and the position now has -10k uPnL.
- When changing leverage, the new intended margin for the position to be open is 5k.
- Under normal behaviour the system should refund (20k - 10k) - 5k = 5k refund.
- However when settling the new leverage, the system only keeps the new intended position margin.
- So the margin balance covering the loss of the -10k uPnL loss is also refunded to Bob.
- Due to this logical error faulty bad debt is inflicted to the system and covered by the insurance fund.
function settleNewLeverage(
CollateralManager storage self,
address account,
uint256 subaccount,
int256 collateralDeltaFromBook,
int256 newMargin,
int256 fundingPayment
) internal returns (int256 collateralDelta) {
int256 currentMargin = self.margin[account][subaccount] - fundingPayment;
int256 collateralDeltaFromPosition = newMargin - currentMargin;
collateralDelta = collateralDeltaFromPosition + collateralDeltaFromBook;
self.handleCollateralDelta(account, collateralDelta);
self.margin[account][subaccount] = newMargin;
}
Recommended mitigation steps
Refactor the function settleNewLeverage to consider the account’s uPnL as well. The uPnL should be taken into account only if its negative value indicates that there is unrealized loss owned by the account holder.
function settleNewLeverage(
CollateralManager storage self,
address account,
uint256 subaccount,
int256 collateralDeltaFromBook,
int256 newMargin,
int256 fundingPayment,
int256 upnl
) internal returns (int256 collateralDelta) {
int256 effectiveUpnl = upnl < 0 ? upnl : int256(0);
int256 currentMargin = self.margin[account][subaccount] + effectiveUpnl - fundingPayment;
int256 collateralDeltaFromPosition = newMargin - currentMargin;
collateralDelta = collateralDeltaFromPosition + collateralDeltaFromBook;
self.handleCollateralDelta(account, collateralDelta);
self.margin[account][subaccount] = newMargin;
[M-02] The protocol doesn’t check whether the orders have expired when the mid and impact price are calculated
Submitted by dimulski, also found by 0xPhantom, Centaur, codertjay, and hgrano
When the markPrice is calculated, the protocol doesn’t check whether the orders from which it takes the price for the mid price and the impact price have expired. This allows a malicious user to submit orders that expire in the next block and manipulate the markPrice. The utilization of twap prices and basis spread EMA will mitigate part of the price discrepancy. However, a malicious user may execute the below described attack every block and thus successfully manipulate the markPrice despite the utilization of twapPrices and the EMA indicator. The PriceHistory::snapshot() function checks whether the last snapshot was taken in the same block.timestamp and if so, only updates the price of the last PriceSnapshot struct, otherwise a new PriceSnapshot struct is created and pushed into the snapshots array. Whether the Market.setMarkPrice() function is called every second or every 5 - 10 seconds doesn’t make much of a difference for this attack path, as a malicious user will have to submit orders that expire in the next block and cancel them in the next block.
- Consider the current
markPricefor the ETH market is 4000e18, the best BUY order has a price of 3990e18 and the best SELL order has a price of 4_010e18, both have an amount of 1e18, and are not expired. - If we are at
block.timestamp10, a malicious user can submit a SELL order with a price of 3_991e18 that expires atblock.timestamp11(the next block). - Now at
block.timestamp11 the protocol admin calls theMarket.setMarkPrice()function, and the indexPrice is 4_004e18, the price increased by 0.1%. - The calculated mid price will be (3990e18 + 3991e18) / 2 = 3990.5e18, the basis spread stored will be 3990.5e18 - 4004e18 = -13.5e18. When it should have been 4000e18 - 4_004e18 = - 4e18.
- Given both the best ask and best bid orders have an amount of 1e18, the calculated impactBid in the
Market::getImpactPrice()function will be ~3991e18 and the calculated impactAsk will be ~3990e18, the impact price will be (3990e18 + 3991e18) / 2 ~ 3990.5e18. When instead it should have been 4000e18. - Now if only those values are pushed, and the
getBasisSpreadEMA()returns -13.5e18 instead of -4e18 and thegetImpactPriceTwap()function returns 3990.5e18 instead of 4000e18, and the provided index price is 4004e18 the mark price will be set to 3990.5e18, as this will be the median price, we can disregard the funding rate for this scenario. Instead the set markPrice should have been much closer to 4_000e18. - If the markPrice is already updated, and at
block.timestamp11 the protocol tries to match an incoming bid order with the best sell order with a price of 3_991e18, the best sell order will be removed, and the bid order will be matched with the next best sell order, if the incoming bid order price is bigger or equal that the not expired best ask order price.
If the order from which we took the price will be removed when the protocol tries to match an incoming order with it, shouldn’t be taken into the calculations of the mid price and the impact price, as that price doesn’t actually exist in the CLOB. Keep in mind that orders can just be expired when the Market.setMarkPrice() function is called, it is not necessary that a malicious actor submits such orders every block, manipulating even 10% of the twap entries, and the entries for the EMA indicator may have a detrimental impact on the newly set markPrice. This represents a big problem because when the markPrice is manipulated the funding rate that users should pay or receive will be completely different, additionally users that shouldn’t be liquidated may get liquidated.
Recommended mitigation steps
When calculating the mid price and the impact price, consider checking whether the orders have expired.
[M-03] EMA-based Mark Price Component Exhibits Excessive Lag Leading to Inaccurate Pricing
Submitted by BenRai, also found by Egbe, jayx, LeoScant, qpzm, and slowpoke
perps/types/Market.sol #L466
The Exponential Moving Average (EMA) calculation for basis spread in mark price determination uses an overly long time window (2,5 hours), causing the mark price to lag significantly behind actual market conditions and potentially leading to inaccurate pricing for up to 2.25 hours.
The perpetual trading system calculates mark price using a median of three components: funding rate component (p1), basis spread EMA component (p2), and impact price TWAP component (p3). According to the developer team, the setMarkPrice() function will be called every 10 seconds by administrators to update market pricing.
The critical issue lies in the getBasisSpreadEMA() function in Market.sol:
function getBasisSpreadEMA(Market storage self) internal view returns (int256 basisSpreadEMA) {
return StorageLib.loadMarketMetadata(self.asset).basisSpreadHistory.ema(15 minutes);
}
The EMA calculation in PriceHistory.sol intends to calculate the EMA over a 15-minute period, but since 15 minutes (15 * 60 = 900) is later used as the number of snapshots to consider in the EMA calculation. This translates to a period of 900 * 10 seconds = 9000 seconds = 2.5 hours instead of the intended 15 minutes.
The basis spread represents the difference between the order book’s mid-price and the index price (midPrice - indexPrice). When market conditions change rapidly (e.g., a lot of orders being placed or cancelled or the indexPrice changing fast), the current basis spread should immediately reflect these changes. However, calculating the EMA over 2.5 hours totally negates this purpose.
Because the EMA price factors into the markPrice, and the markPrice is used to calculate the funding rate, this issue will lead to an inaccurate funding rate.
Recommended mitigation steps
Implement one of the following solutions:
- Reduce EMA period: Change the number of snapshots used for the calculation of the EMA component to represent the intended calculation period of 15 minutes by only using 15 * 60 / 10 = 90 snapshots.
- Time-weighted EMA: Implement timestamp-based EMA calculation that considers actual time intervals rather than snapshot counts. For this it will be necessary to save the
baseSpreadsnapshots with a time stamp and determine the snapshots to be used for the EMA calculation by this timestamp.
View detailed Proof of Concept
[M-04] Leverage Increase Breaks Margin Removal Invariant
Submitted by BenRai, also found by 0xterrah, Bigsam, and Rhaydden
perps/PerpManager.sol #L176-L219
The setPositionLeverage() function allows users to bypass margin removal restrictions by increasing leverage, effectively extracting margin that would otherwise be blocked by removeMargin()’s safety checks.
The perpetual trading system implements margin management through two key functions: removeMargin() and setPositionLeverage(). While removeMargin() enforces strict restrictions to prevent excessive margin extraction from profitable positions, setPositionLeverage() lacks equivalent safeguards, creating a bypass mechanism.
The removeMargin() function enforces margin requirements through assertPostWithdrawalMarginRequired() which ensures that margin + upnl >= max(intendedMargin, totalNotional / 10). This restriction is designed to prevent users from extracting too much margin from profitable positions, maintaining adequate collateralization.
However, setPositionLeverage() only validates against assertOpenMarginRequired(), which uses a different calculation: margin + upnl >= minOpenMargin. The key difference is that assertOpenMarginRequired() uses getMinOpenMargin() which calculates margin requirements based on maxOpenLeverage of each market rather than the user’s actual leverage, while assertPostWithdrawalMarginRequired() uses the user’s actual leverage via getIntendedMargin().
When a user increases leverage:
- The
intendedMargindecreases (sinceintendedMargin = currentNotional / newLeverage) - The
settleNewLeverage()function adjusts the margin balance to match the new intended margin - This effectively allows margin extraction that would be blocked by
removeMargin()
[!NOTE] The
assertPostWithdrawalMarginRequired()function includes the critical line:intendedMargin = intendedMargin.max(totalNotional / 10)which prevents excessive margin extraction from high-profit positions, but this protection is absent insetPositionLeverage().
This vulnerability allows users to bypass critical margin safety mechanisms designed to protect the protocol from margin reduction based on unrealized pnl. Users can extract margin from profitable positions that would otherwise be restricted, potentially leading to increased liquidation risk and protocol instability. The bypass undermines the core risk management system of the perpetual trading platform.
Recommended mitigation steps
To prevent excessive margin reduction for profitable positions, ensure that a reduction of leverage is always possible. But if the leverage is increased, make sure to apply the same margin restrictions as removeMargin(). Keep in mind, that this might prevent the user from increasing his leverage above 10 if there is little to no unrealized pnl in the account.
Alternatively ensure that the indentedMargin with the new leverage is fully covered by the existing margin of the account, ignoring the unrealized pnl. This would still allow the user to increase his leverage (which is legitimate) but ensure that there is still enough “real” margin in the account to account for the intended leverage.
View detailed Proof of Concept
[M-05] Protocol Disable Functionality Bypass Allows Critical Operations During Emergency Shutdown
Submitted by BenRai, also found by anchabadze and Riceee
perps/PerpManager.sol#L97perps/PerpManager.sol#L136-L139
The protocol’s emergency shutdown mechanism fails to properly restrict critical margin and collateral management functions, allowing users to continue modifying positions and managing funds even when the protocol is disabled.
The GTE perpetuals protocol implements an emergency shutdown mechanism through the deactivateProtocol() function in AdminPanel.sol, which sets the active flag in the clearing house to false. This mechanism is designed to halt all trading and position management activities during emergencies or maintenance periods.
The onlyActiveProtocol modifier is defined in PerpManager.sol:
modifier onlyActiveProtocol() override (AdminPanel, LiquidatorPanel) {
if (!StorageLib.loadClearingHouse().active) revert ProtocolNotActive();
_;
}
However, several critical functions that should be restricted during protocol emergency shutdown are missing this modifier:
-
Margin Management Functions:
addMargin()- Allows adding margin to existing positionsremoveMargin()- Allows removing margin from positionssetPositionLeverage()- Allows changing position leverage
-
Collateral Management Functions:
deposit()- Allows depositing free collateralwithdraw()- Allows withdrawing free collateraldepositTo()- Allows depositing to other accountsdepositFromSpot()- Allows depositing from spot accountwithdrawToSpot()- Allows withdrawing to spot account
The inconsistency becomes apparent when comparing these functions to trading functions like placeOrder(), which correctly includes the onlyActiveProtocol modifier and properly reverts when the protocol is disabled.
[!NOTE] The protocol correctly restricts order placement, amendment, and cancellation functions, but fails to apply the same restrictions to margin and collateral management functions.
This creates a scenario where, during an emergency shutdown when the protocol needs to halt all user activities, a user can still increase his leverage, remove margin from a subaccount and withdraw the collateral from the protocol.
Recommended mitigation steps
Add the onlyActiveProtocol modifier to all margin and collateral management functions that should be restricted during protocol shutdown.
View detailed Proof of Concept
[M-06] Liquidation stalls when top-of-book is outside divergence band (STANDARD & BACKSTOP), allowing under-margined positions to persist
Submitted by 4Nescient, also found by 0xShitgem, BenRai, and nikhil840096
During liquidation, the protocol submits a reduce-only IOC order via Market.liquidate(...). Matching is gated by divergence bands around the mark:
Long liquidation (SELL into bids) must see bestBid ≥ mark × (1 − cap).
function _matchIncomingBid(
Book storage ds,
Order memory incomingOrder,
bool baseDenominated
) internal returns (uint256 quoteSent, uint256 baseReceived) {
uint256 bestAsk = ds.getBestAsk();
uint256 maxAsk = StorageLib
.loadMarket(ds.config.asset)
.getMaxDivergingAskPrice();
while (bestAsk <= incomingOrder.price && incomingOrder.amount > 0) {
if (bestAsk == type(uint256).max) break;
@> if (bestAsk > maxAsk) break;
Limit storage limit = ds.askLimits[bestAsk];
Order storage bestAskOrder = ds.orders[limit.headOrder];
if (bestAskOrder.isExpired()) {
_removeUnfillableOrder(ds, bestAskOrder);
bestAsk = ds.getBestAsk();
continue;
}
__TradeData__ memory data = _matchIncomingOrder(
ds,
bestAskOrder,
incomingOrder,
baseDenominated
);
incomingOrder.amount -= data.filledAmount;
baseReceived += data.baseTraded;
quoteSent += data.quoteTraded;
if (limit.numOrders == 0) bestAsk = ds.getBestAsk();
}
}
Short liquidation (BUY into asks) must see bestAsk ≤ mark × (1 + cap).
function _matchIncomingAsk(
Book storage ds,
Order memory incomingOrder,
bool baseDenominated
)
internal
returns (uint256 totalQuoteTokenReceived, uint256 totalBaseTokenSent)
{
uint256 bestBid = ds.getBestBid();
uint256 minBid = StorageLib
.loadMarket(ds.config.asset)
.getMaxDivergingBidPrice();
while (bestBid >= incomingOrder.price && incomingOrder.amount > 0) {
if (bestBid == 0) break;
@> if (bestBid < minBid) break;
Limit storage limit = ds.bidLimits[bestBid];
Order storage bestBidOrder = ds.orders[limit.headOrder];
if (bestBidOrder.isExpired()) {
_removeUnfillableOrder(ds, bestBidOrder);
bestBid = ds.getBestBid();
continue;
}
__TradeData__ memory data = _matchIncomingOrder(
ds,
bestBidOrder,
incomingOrder,
baseDenominated
);
incomingOrder.amount -= data.filledAmount;
totalQuoteTokenReceived += data.quoteTraded;
totalBaseTokenSent += data.baseTraded;
if (limit.numOrders == 0) bestBid = ds.getBestBid();
}
}
If the top-of-book quote is outside its allowed band (which implies no in-band quotes exist on that side), the matcher halts immediately and executes no trades. Because the order is IOC (not postable), the matcher returns zero effect, causing a ZeroOrder() revert and the liquidation aborts.
Shared gate across books. This divergence gate is applied identically to both the STANDARD and BACKSTOP order books. While enforcing a divergence cap on STANDARD is reasonable (maker price protection / slippage control), applying the same gate on BACKSTOP is problematic:
Backstop’s job is risk reduction, not price protection. Sharing the STANDARD divergence band means the system can refuse to liquidate even on the backstop, whenever there are no in-band quotes on the relevant side (i.e., all asks > mark × (1 + cap) for BUY liquidation, or all bids < mark × (1 − cap) for SELL liquidation). That is exactly when you most need the backstop to trade.
Impact (why it matters)
- Denial of liquidation: If there are no quotes within the divergence band on the relevant side, liquidation cannot proceed even on BACKSTOP.
- Backstop availability failure: The “last-resort” venue becomes unavailable under stress whenever its side of book is entirely out of band or empty, defeating its risk-reduction purpose.
- Escalating loss: The account can accrue further losses and go negative equity (bad debt).
- Systemic impact: Losses hit the Insurance Fund; if insufficient, the system may auto-deleverage (ADL) solvent traders.
- Realistic stress/grief condition: In thin/volatile markets, in-band liquidity can disappear (market makers widen or pull quotes), leaving only out-of-band quotes. An attacker doesn’t need to post a special quote; it’s enough that no one maintains an in-band quote on BACKSTOP, which predictably blocks liquidation during critical windows.
Recommended mitigation steps
Any of the below (individually sufficient):
- Bypass divergence checks for BACKSTOP liquidation orders.
- Execute at the band boundary (e.g.,
clamp to mark×(1±cap)) with an explicit liquidation penalty. - Provide a non-CLOB backstop (e.g., system LP) that always fills liquidation within bounded slippage when divergence gating blocks the book.
View detailed Proof of Concept
[M-07] Partial Fills before amendOrder TX exposes Users to unintended Risks
Submitted by VinciGearHead
perps/PerpManager.sol #L328-L340
The implementation of the AmendLimitOrder opens a vulnerability that allows a user’s amendment transaction to be executed on an outdated order state, leading to users silently taking on or creating more additional exposure than intended, producing real liquidation/financial risk.
The issue stems from the fact that amendLimitOrder does not validate whether the order has been partially filled before applying modifications the specified baseAmount. In a Perps environment, transactions are processed asynchronously and miners/sequencers may include other trades before the amendLimtiOrder tx is executed. As a result, by the time the amendment transaction reaches execution, the order might have already been partially filled, hence the system might think the user wants to increase their order size.
To amend an order we call this external amendLimitOrder function:
filename : PerpManager.sol (excerpt)
function amendLimitOrder(address account, AmendLimitOrderArgs calldata args)
external
onlySenderOrOperator(account, PerpsOperatorRoles.PLACE_ORDER)
onlyActiveProtocol
returns (int256 collateralDelta)
{
ClearingHouse storage clearingHouse = StorageLib.loadClearingHouse();
collateralDelta = clearingHouse.market[args.asset].amendLimitOrder(account, args, BookType.STANDARD);
StorageLib.loadCollateralManager().handleCollateralDelta({account: account, collateralDelta: collateralDelta});
}
which eventually calls the internal _processAmend function. Now the implementation of the _processAmend determines which type of amendment should take place depending on the user input.
filename : OrderLib.sol (excerpt)
function _processAmend(Book storage ds, Order storage order, AmendLimitOrderArgs calldata args)
internal
returns (int256 notionalDelta, int256 collateralDelta)
{
if (
args.expiryTime.isExpired()
|| args.baseAmount < StorageLib.loadBookSettings(ds.config.asset).minLimitOrderAmountInBase
) {
revert InvalidAmend();
} else if (order.side != args.side || order.price != args.price) {
// change place in book
return _executeAmendNewOrder(ds, order, args);
} else {
// change amount
@>> return _executeAmendAmount(ds, order, args);
}
}
For cases to change not too sensitive properties of the order like (size, Expiry), the _executeAmendAmount() is invoked, which sets the order size directly to the new baseAmount , which can be problematic in some cases.
/// @dev Performs the updating of an amended order with a new amount
function _executeAmendAmount(Book storage ds, Order storage order, AmendLimitOrderArgs calldata args)
internal
returns (int256 notionalDelta, int256 collateralDelta)
{
......................
if (order.reduceOnly != args.reduceOnly) {
if (args.reduceOnly) {
StorageLib.loadMarket(ds.config.asset).linkReduceOnlyOrder(
order.owner, order.subaccount, args.orderId, ds.config.bookType
);
} else {
StorageLib.loadMarket(ds.config.asset).unlinkReduceOnlyOrder(
order.owner, order.subaccount, args.orderId, ds.config.bookType
);
}
}
@>> order.amount = args.baseAmount;
order.reduceOnly = args.reduceOnly;
order.expiryTime = args.expiryTime;
}
Scenario
- Alice places an order to buy
10 ETHat$2,000. - Alice now decides to change the
expirywhile the order is still pending, but as of now the order hasn’t been filled at all. - Before Alice’s
amendLimitOrdertransaction is mined,9 ETHfrom the original order was now partially filled. - Alice’s
amendLimitOrderis processed and still assumes the original10 ETHsize, only modifying the expiry.
Resulting State
9 ETHalready filledRemaining orderincorrectly updated to10 ETH(instead of1 ETH)
Alice has unintentionally taken on almost double the intended financial exposure:
- Initial intended exposure:
10 ETH - Actual exposure after race:
19 ETH
Recommended mitigation steps
Before applying an amendment in the size of the order, we need to know that the order’s filled amount has not changed since tx submission, this way we can protect user against unwanted trades.
View detailed Proof of Concept
[M-08] Reduce-only orders can be used to inflate quoteOI and DoS the orderbook
Submitted by VinciGearHead, also found by HighKingMargo and udogodwin
perps/types/Book.sol #L391-L403
A malicious user may place a reduce-only order with a tiny amount but an arbitrarily large price. The book code unconditionally increases metadata.quoteOI (for BUY) when the order is added to the book.
The addOrderToBook in book.sol library is called when adding order to a book. It manages the data structure and also calculates the new open interest of the market.
function addOrderToBook(Book storage self, Order memory order) internal {
if (order.reduceOnly) {
StorageLib.loadMarket(self.config.asset).linkReduceOnlyOrder(
order.owner, order.subaccount, order.id.unwrap(), self.config.bookType
);
}
Limit storage limit = _updateBookPostOrder(self, order);
_updateLimitPostOrder(self, limit, order);
self.orders[order.id] = order;
}
The calculation of the new open interest of the BUY side is a function of the order’s price and amount, for reduce-only order no collateral is involved in setting up the order as a result an attacker can craft a price so close (type(uint256).max) to cause other user’s BUY order to overflow.
function _updateBookPostOrder(Book storage self, Order memory order) private returns (Limit storage limit) {
if (order.side == Side.BUY) {
limit = self.bidLimits[order.price];
if (limit.numOrders == 0) self.bidTree.insert(order.price);
self.metadata.numBids++;
@>> we can push this to type(uint256).max arbitrarily
@>> self.metadata.quoteOI += order.amount.fullMulDiv(order.price, 1e18);
} else {
limit = self.askLimits[order.price];
if (limit.numOrders == 0) self.askTree.insert(order.price);
self.metadata.numAsks++;
self.metadata.baseOI += order.amount;
}
}
Because reduce-only orders are meant to reduce exposure (not create it), counting them toward open interest lets the attacker pump quoteOI without meaningful risk and cause other users’ placeOrder calls to revert.
Impact
This bug allows an attacker to DOS the order book by placing reduce-only order with tiny size and an arbitrarily large price to massively inflate metadata.quoteOI causing other users’ BUY Order to revert.
Recommended mitigation steps
Skip updating quoteOI/baseOI for reduce-only orders or enforce notional and price caps relative to mark price so attackers cannot inflate open interest with extreme values.
View detailed Proof of Concept
[M-09] GTL contract is vulnerable to an inflation attack
Submitted by IzuMan, also found by insecuremary, lostOpcode, and menace
perps/GTL.sol #L292-L294
GTL contract is vulnerable to an inflation attack. As a result, a user can deposit the asset in the GTL contract and receive no shares. This will cause a loss of funds for users.
The GTL contract inherits the ERC4626 contract from the solady library, but does not implement any of the provided protection methods leaving a newly deployed GTL contract vulnerable to an inflation attack.
Here is an explanation of an inflation attack if the reader is unfamiliar: https://mixbytes.io/blog/overview-of-the-inflation-attack#rec558238760
Recommended mitigation steps
Use one of the provide methods inside the solady ERC4626 contract:
- Decimal offset
- Virtual shares
- Fixed initial conversion rate when minting shares
View detailed Proof of Concept
[M-10] Incorrect Removal Of Asset In UpdateAccount Due To Wrong Implementation in _movePop
Submitted by SeveritySquad, also found by 0xTiwa, dreamcoder, jpmendes, kimnoic, NexusAudits, pfapostol, udogodwin, and valicera
perps/types/ClearingHouse.sol #L729-L736
A wrong implementation in the _movePop function causes the wrong element to be removed from the account’s asset list whenever a position is closed. Instead of removing the intended asset, _movePop removes the last element of the array while leaving the closed position’s asset in place.
This causes incorrect changes in the asset list (assets) and therefore causes still open positions to be removed from the contracts accounting, breaking a core accounting functionality of the protocol.
Every asset in the list corresponds to a position with amount > 0 and every position with amount > 0 must appear in the asset list for each subaccount.
The wrong implementation in _movePop is as follows:
function _movePop(DynamicArrayLib.DynamicArray memory array, bytes32 asset) private pure {
uint256 index = array.indexOf(asset);
if (index == type(uint256).max) return;
@> array.set(index, asset);
array.pop();
}
Intended Behaviour: Swap the target element slot with the last element and pop the last element
Actual Behaviour: Leaves the target asset untouched and always pops the last element in the array
When updateAccount calls _movePop:
@> if (positions[positionIdx].amount == 0) _movePop(assets, tradedAsset);
self.setAssets(account, subaccount, assets.length(), tradedAsset);
The wrong element is removed, and the corrupted assets list is then added to storage via setAssets.
Impact
-
State discrepancies
- The closed asset
(amount == 0)remains in the account’s assets list. - A different asset (the last one) is removed, even if its position is still active.
- The closed asset
-
Possible Incorrect margin/collateral accounting
- Active positions will disappear from the
assetslist, leading to missed collateral contributions or skipped liabilities. - Zeroed positions will remain listed, disrupting accounting
- Active positions will disappear from the
-
Liquidation safety risk
- An undercollateralized account may avoid liquidation if an underwater position, causing asset is popped (cross margin).
-
Possible Inaccessibility of Funds
- Assets with active balances but missing from the list will become unreachable by user or system operations.
Recommended mitigation steps
Fix the _movePop function to replace the target element for the last element of the array, and then pop the last element.
View detailed Proof of Concept
[M-11] Funding Mis-Accrual Due to Missing Time Normalization in _calcFundingIndex
Submitted by Psycharis, also found by ifex445, IvanAlexandur, and Manga
perps/types/FundingRateEngine.sol #L98-L117
The bug stems from missing time-proportional scaling in funding accrual.
In contracts/perps/types/FundingRateEngine.sol, the cumulativeFundingIndex is incremented as:
fundingIndex = fundingRate × indexTwap;
but without scaling by the elapsed time relative to the effective funding interval (either fundingInterval or resetInterval during clamp/reset).
- Elapsed time is used for TWAPs and interval checks via
assertFundingIntervalElapsed. - However, the accrual step ignores elapsed time.
Violation of invariants
This makes funding path-dependent:
- Performing N settlements each of length
Δtresults in ≈N × funding - A single settlement over total
N × Δtresults in much less
Mathematically, the protocol breaks the invariant:
$$ \Delta CFI(\text{10h}) \neq \sum_{i=1}^{10} \Delta CFI(\text{1h}) $$Thus, cumulative funding no longer depends only on elapsed time and price path, but also on settlement frequency.
Impact
- Users may be charged or credited incorrect funding, leading to unfair transfers between longs and shorts.
- Errors accumulate over time, distorting PnL, accounting, insurance-fund flows, and potentially causing unintended liquidations.
- Even without malice (e.g., downtime), the protocol mis-accrues funding.
- If settlement frequency can be influenced, actors can deliberately amplify or dampen funding to their advantage.
Recommended mitigation steps
- Add time-proportional scaling — scale per-interval funding by
elapsed / baseInterval. - Unit tests for path-independence — confirm that 1×10h = 10×1h accrual.
- Monitoring — track expected vs. realized accrual and alert on deviations.
View detailed Proof of Concept
[M-12] Perps - GTL post-only orders skew totalAssets accounting, minting excessive shares and rendering the GTL vault insolvent
Submitted by lodelux
perps/types/ClearingHouse.sol #L108-L144
When the GTL vault places the first post-only limit order for a subaccount, ClearingHouse::placeOrder returns early without setting the new subaccount in the internal accounting of GTL. This causes GTL::totalAssets to NOT account for the posted collateral on the orderBook for that specific subaccount meaning that mintedShares = depositedAssets / totalAssets are inflated, making the vault insolvent.
Finding description
The GTL vault acts as a ERC4626 compliant liquidity pool where users can deposit USDC in exchange for shares of the pool. The protocol then uses funds to place orders on the perp book and user’s shares represent their portion of the NAV controlled by the vault.
Specifically, when a user calls GTL::deposit with assets, he receives shares = assets / (totalAssets() + 1 ) * (totalSupply() + 1)
where totalAssets is:
function totalAssets() public view override returns (uint256) {
return usdc.balanceOf(address(this)) + orderbookCollateral() + freeCollateralBalance() + totalAccountValue();
}
function orderbookCollateral() public view returns (uint256 collateral) {
uint256[] memory subaccounts = _subaccounts.values();
@> for (uint256 i; i < subaccounts.length; ++i) {
collateral += IViewPort(perpManager).getOrderbookCollateral(address(this), subaccounts[i]);
}
}
Notice that the posted orderBook collateral is accounted only for the locally stored subaccounts.
These subaccounts are added via GTL:addSubaccount which is an onlyPerpManager function, and it’s called inside ClearingHouse:setAssets:
function setAssets(
ClearingHouse storage self,
address account,
uint256 subaccount,
uint256 newLength,
bytes32 asset
) internal {
uint256 oldLength = self.assets[account][subaccount].length();
if (oldLength == newLength) return;
if (oldLength < newLength) self.assets[account][subaccount].add(asset);
else self.assets[account][subaccount].remove(asset);
if (account == Constants.GTL) {
@> if (oldLength == 0) IGTL(Constants.GTL).addSubaccount(subaccount);
else if (newLength == 0) IGTL(Constants.GTL).removeSubaccount(subaccount);
}
}
More specifically, they are added whenever an order is filled on a new subaccount (as before assets for that subaccount in perpManager was empty) as this function is called by ClearingHouse::updateAccount which is itself called only in either _processTakerFill or _processMakerFill. When the GTL admin places a post-only order, this fn handles it:
function placeOrder(ClearingHouse storage self, address account, PlaceOrderArgs calldata args, BookType bookType)
internal
returns (PlaceOrderResult memory orderResult)
{
Market storage market = self.market[args.asset];
orderResult = market.placeOrder(account, args, bookType);
uint256 collateralPosted;
if (orderResult.basePosted > 0 && !args.reduceOnly) {
collateralPosted = _getCollateral(
orderResult.basePosted, args.limitPrice, market.getPositionLeverage(account, args.subaccount)
);
}
@> if (orderResult.baseTraded == 0) {
StorageLib.loadCollateralManager().handleCollateralDelta({
account: account,
collateralDelta: collateralPosted.toInt256()
});
return orderResult;
}
_processTakerFill(
self,
__FillParams__({
asset: args.asset,
account: account,
subaccount: args.subaccount,
side: args.side,
quoteAmount: orderResult.quoteTraded,
baseAmount: orderResult.baseTraded,
collateralPosted: collateralPosted
})
);
}
As you can see if baseTraded == 0, meaning that the order is a post-only, _processTakerFill is never executed.
What this means is whenever the GTL admin places an order on a new subaccount which doesn’t immediately fill, this subaccount is not added to the GTL _subaccounts set even though collateral was indeed posted from GTL to the book, causing now the totalAssets call to not return this collateral in the calculations. This subaccount is lastly added once the order is filled and processMakerOrder is called.
Impact
This means that, for deposits that happen while this subaccount is not added but the collateral is moved from GTL to the book, the shares minted are inflated, giving much more USDC to these depositors than they should be entitled to. Effectively wrongly transferring USDC from some depositors to others.
View detailed Proof of Concept
[M-13] Max Leverage Reduction Not Enforced for Legacy Positions
Submitted by BenRai
perps/types/Market.sol #L382-L390
The system fails to enforce reduced maximum leverage limits when opening new positions, allowing users to maintain higher leverage than the current market maximum by leveraging legacy position leverage settings.
Finding description
The GTE perpetuals system implements leverage controls through the maxOpenLeverage parameter in market settings, which is intended to limit the maximum leverage users can employ. However, the system has a critical flaw in how it handles leverage validation during position opening.
The leverage validation system works as follows:
MarketLib.assertMaxLeverage()validates that leverage is between 1x and the currentmaxOpenLeveragesetting- This validation is only called in
PerpManager.setPositionLeverage()when users explicitly set their position leverage - However, when positions are opened through order execution, no leverage validation occurs against the current
maxOpenLeverage
The position opening flow works through the following components:
- Orders are placed via
ClearingHouse.placeOrder()→Market.placeOrder()→CLOBLib.placeOrder() - When orders are filled,
ClearingHouse._processTakerFill()processes the trade - The leverage for a new position is determined in
ClearingHouseLib._getPostiion()by callingMarket.getPositionLeverage()
function getPositionLeverage(Market storage self, address account, uint256 subaccount)
internal
view
returns (uint256 leverage)
{
leverage = self.position[account][subaccount].leverage;
if (leverage == 0) leverage = 1e18; // default leverage
}
When a user opens a new position, the system uses their existing position leverage (stored in self.position[account][subaccount].leverage) without checking if it exceeds the current maxOpenLeverage. This means:
- Legacy High Leverage: Users who previously set high leverage (e.g., 50x) can continue opening new positions at that leverage even after
maxOpenLeverageis reduced (e.g., to 25x) - Position Size Increases: Users can increase their position size at the old high leverage, effectively bypassing the reduced leverage limits
- Admin Intent Bypass: When administrators reduce
maxOpenLeveragefor risk management, existing users can circumvent these restrictions
Higher leverage than intended by the admin brings higher risk for bad debt in the market which must be covered by the endurance fund.
Impact Explanation
High Impact - This vulnerability allows users to bypass critical risk management controls. When administrators reduce maximum leverage limits (typically during high volatility or market stress), the intended risk reduction is completely circumvented. Users can maintain dangerous leverage levels that could lead to cascading liquidations and systemic risk to the protocol.
Likelihood Explanation
High Likelihood - This issue will occur whenever administrators reduce maxOpenLeverage settings, which is a common risk management practice during volatile market conditions. Any user with existing high-leverage positions can easily exploit this by not adjusting the leverage of their position manually.
Recommendation
Add leverage validation when the leverage for a new position is determined to ensure compliance with current maxOpenLeverage settings. If the position leverage is too high, either revert the function call or adjust the leverage to maxOpenLeverage.
Additionally, consider enforcing that an open position with leverage exceeding the new maxOpenLeverage can only be reduced in size, not increased.
View detailed Proof of Concept
[M-14] Margin Balance can be forced to be < 0 even realizing upnl indirectly
Submitted by dan__vinci
perps/types/Position.sol #L90-L128
The protocol currently prevents users from RemovingMargin such that the margin balance is negative this way even if the equity i.e. profit of the account is good enough the user can’t realize their upnl without closing the position.
function removeMargin(address account, uint256 subaccount, uint256 amount)
external
onlySenderOrOperator(account, PerpsOperatorRoles.WITHDRAW_MARGIN)
{
ClearingHouse storage clearingHouse = StorageLib.loadClearingHouse();
__MarginUpdateCache__ memory cache;
// load account
(cache.assets, cache.positions) = clearingHouse.getAccount(account, subaccount);
if (amount == 0) revert InvalidWithdraw();
if (cache.positions.length == 0) revert InvalidWithdraw();
// realize funding payment
cache.fundingPayment = ClearingHouseLib.realizeFundingPayment(cache.assets, cache.positions);
// settle margin update
int256 remainingMargin = StorageLib.loadCollateralManager().settleMarginUpdate({
account: account,
subaccount: subaccount,
marginDelta: -amount.toInt256(),
fundingPayment: cache.fundingPayment
});
// assert post withdraw margin requirement (margin + upnl) >= max(intendedMargin, totalNotional / 10)
// where intendedMargin is the sum of notional / leverage for open positions
@>>> clearingHouse.assertPostWithdrawalMarginRequired({
assets: cache.assets,
positions: cache.positions,
margin: remainingMargin
});
// set position update (note: this will just be the new position.lastCumulativeFunding)
clearingHouse.setPositions({
tradedAsset: "",
account: account,
subaccount: subaccount,
assets: cache.assets,
positions: cache.positions
});
emit MarginRemoved(account, subaccount, amount, remainingMargin, StorageLib.incNonce());
}
function assertPostWithdrawalMarginRequired(
ClearingHouse storage self,
DynamicArrayLib.DynamicArray memory assets,
Position[] memory positions,
int256 margin
) internal view {
if (margin < 0) revert MarginRequirementUnmet();
(uint256 intendedMargin, int256 upnl) = _getIntendedMarginAndUpnl(self, assets, positions);
uint256 totalNotional = self.getNotionalAccountValue(assets, positions);
intendedMargin = intendedMargin.max(totalNotional / 10);
if (margin + upnl < intendedMargin.toInt256()) revert MarginRequirementUnmet();
}
But a malicious user can still systematically perform this by having multiple positions and one in good profit enough equity currently. The protocol allows a user to call removeMargin(...) and withdraw their entire posted margin as long as the account equity (margin + upnl) meets assertPostWithdrawalMarginRequired(). However, assertPostWithdrawalMarginRequired() permits margin to become 0 so long as margin + upnl >= intendedMargin.
Later, the user will close one of his other positions. The closing flow returns a marginDelta amount (proportional refund of the original IM slice) into the account’s free collateral. Because the user previously withdrew their margin, this returned marginDelta becomes newly available free collateral that the attacker can immediately withdraw — effectively extracting more than they had originally deposited while positions were opened without real collateral backing.
This creates a replayable drain: withdraw margin while in profit → close (or partially close) positions to trigger marginDelta refunds → withdraw the refunded margin → repeat.
function _close(Position memory self, Side side, uint256 quoteTraded, uint256 baseTraded)
private
pure
returns (PositionUpdateResult memory result)
{
__CloseCache__ memory cache; //@audit this is the cache nigga
//@audit-info the closeSize we want to close is the self.amount.min
cache.closeSize = self.amount.min(baseTraded);
// pro rate quote amounts by close
cache.closedOpenNotional = self.openNotional.fullMulDiv(cache.closeSize, self.amount);
cache.currentNotional = quoteTraded.fullMulDiv(cache.closeSize, baseTraded);
result.rpnl = _pnl(self.isLong, cache.closedOpenNotional, cache.currentNotional);
//@audit-info we have this marginDelta -closedOpenNotional
@>> result.marginDelta = -cache.closedOpenNotional.fullMulDiv(1e18, self.leverage).toInt256();
self.openNotional -= cache.closedOpenNotional;
self.amount -= cache.closeSize; //@audit-info we reduced the value as appropriately
quoteTraded -= cache.currentNotional;
baseTraded -= cache.closeSize; //@audit-info we reduce this guy too appropriately
if (self.isLong) result.oiDelta.long = -cache.closeSize.toInt256();
else result.oiDelta.short = -cache.closeSize.toInt256();
if (result.sideClose = self.amount == 0) {
// reverse open
if (baseTraded > 0) { //@audit-info if we have a remaining order to push into the system
result.marginDelta = _open(self, side, quoteTraded, baseTraded); //@audit-info we get the marginDelta
if (self.isLong) result.oiDelta.long += baseTraded.toInt256();
else result.oiDelta.short += baseTraded.toInt256();
} else {
// full close, set to defaults
delete self.lastCumulativeFunding;
delete self.isLong;
}
}
}
The root cause is that the way finalMarginDelta is calculated, it depends on the equity of the user. Blindly any user with enough equity more than the intendedMargin can easily perform this attack.
function rebalanceClose(
ClearingHouse storage self,
DynamicArrayLib.DynamicArray memory assets,
Position[] memory positions,
int256 margin,
int256 marginDelta
) internal view returns (int256 finalMargin, int256 finalMarginDelta) {
(uint256 intendedMargin, int256 upnl) = _getIntendedMarginAndUpnl(self, assets, positions);
// full close
if (intendedMargin == 0) {
if (margin < 0) return (margin, 0);
else return (0, -margin);
}
int256 equity = margin + upnl;
// finalMarginDelta = MAX(marginDelta, MIN(intendedMargin - equity, 0))
// marginDelta on a decrease is -(closedOpenNotional / leverage), where
// closedOpenNotional = position.openNotional * closedAmount / position.amount
@>> finalMarginDelta = marginDelta.max((intendedMargin.toInt256() - equity).min(0));
finalMargin = margin + finalMarginDelta;
}
If the (intendedMargin.toInt256() - equity) is less than the marginDelta which we use to open the position initially, the marginDelta is returned directly regardless if it was already consumed by removeMargin.
Impact
This bug is a critical bug as a user with enough equity upnl in one position can realize it indirectly with closing off another position.
Recommended mitigation steps
On close, do not refund marginDelta if that portion of IM was already withdrawn. When calculating finalMarginDelta to return on close, cap the refund to the actual reserved IM remaining for that position.
View detailed Proof of Concept
[M-15] Loss for protocol by incorrectly assuming the position has been fully closed
Submitted by dhank, also found by 0xsagetony, axelot, dan__vinci, and hgrano
perps/modules/LiquidatorPanel.sol #L262-L266
In the fn backstopLiquidate() the check for finding whether the position is fully closed before writing-off the margin balance is incomplete.
// realize bad debt if underwater and full close
if (cache.margin < 0 && cache.positions.length == 1) {
//@audit-issue backstop liquidate entire position but if the placed order is partially filled there will be remaining position amount.
fee += cache.margin;
delete cache.margin;
}
When liquidator calls backstopLiquidate(), the liquidator places a IOC type fill order with amount = position.amount.
perps/types/Market.sol #L185
But this doesn’t ensure that the entire position.amount will be traded in the given execution. Especially in case of backstop orderbook where less orders are expected in the orderBook compared to the standard order Book, it is not necessary that there will be enough orders in the orderBook that matches the given incoming liquidation orders size.
Since it is an IOC type order, it won’t revert even if the mentioned amount is not filled completely.
But the code incorrectly writes off the entire margin balance at the protocol’s expense, under the false assumption that the user no longer holds any position.
Recommended mitigation steps
Either place FOK orders in case of backstop liquidation or check whether the position.amount == 0 also before writing off the user’s margin balance.
GTE Launchpad
This section of the report includes findings associated with the GTE Launchpad contracts.
High Risk Findings (8)
[H-03] GTELaunchpadV2Pair::burn over-estimates distribution amounts when there are non-zero accrued launchpad fees
Submitted by hgrano, also found by 0rpse, 0xnija, AasifUsmani, AvantGard, codegpt, IvanAlexandur, KuwaTakushi, nachin, saraswati, serial-coder, Web3Vikings, Willi-I-Am, and ZeroEx
launchpad/uniswap/GTELaunchpadV2Pair.sol #L217-L218
When burning LP tokens, the GTELaunchpadV2Pair contract calculates the amounts of base/quote tokens to distribute based on the current balances (lines 217-218):
amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution
amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution
This seems ok but the problem is that the balances will also include accrued launchpad fees, which are owed to the distributor, so liquidity providers should have no claim over these fees. Furthermore, when minting, the amount of liquidity to mint is calculated based on reserves, not balances - and reserves will always have accrued launchpad fees deducted from them.
We see this on GTELaunchpadV2Pair.sol:196:
liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
An attacker can do the following in a single transaction:
- Perform swap(s) to ensure the accrued launchpad fees are non-zero.
- Mint LP tokens.
- Burn their LP tokens and capture a profit due to the fact they have a claim on a portion of the contract balances not contract reserves.
- Repeat steps 2-3 many times to increase their profit.
Impact: theft of funds from the pair contract. Given it can be repeated at will by the attacker, this could lead to theft of almost all liquidity from the pair.
Recommended mitigation steps
When calculating the amounts to distribute when burning LP tokens, make sure to deduct any accrued launchpad fees from the balances as shown:
--- a/contracts/launchpad/uniswap/GTELaunchpadV2Pair.sol
+++ b/contracts/launchpad/uniswap/GTELaunchpadV2Pair.sol
@@ -214,8 +214,8 @@ contract GTELaunchpadV2Pair is IUniswapV2Pair, IGTELaunchpadV2Pair, UniswapV2ERC
bool feeOn = _mintFee(_reserve0, _reserve1);
uint256 _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
- amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution
- amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution
+ amount0 = liquidity.mul(balance0 - accruedLaunchpadFee0) / _totalSupply; // using balances ensures pro-rata distribution
+ amount1 = liquidity.mul(balance1 - accruedLaunchpadFee1) / _totalSupply; // using balances ensures pro-rata distribution
View detailed Proof of Concept
[H-04] Attacker can drain funds from GTELaunchPadV2Pair using swap
Submitted by hgrano, also found by 0rpse, 0xPhantom, AasifUsmani, AvantGard, BowTiedOriole, dimulski, hezze, IvanAlexandur, jesjupyter, Matin, max10afternoon, nuthan2x, Nyxaris, saraswati, serial-coder, VAD37, Web3Vikings, and ZeroEx
launchpad/uniswap/GTELaunchpadV2Pair.sol #L250-L251
If we consider a situation where there are some non-zero accrued launchpad fees on the GTELaunchPadV2Pair, then the reserves are calculated as current balances subtract accrued fees within the _update function (GTELaunchPadV2Pair.sol lines 154-155):
reserve0 = _reserve0 = uint112(balance0) - totalLaunchpadFee0;
reserve1 = _reserve1 = uint112(balance1) - totalLaunchpadFee1;
Now an attacker can call swap and the function infers the amount(s) transferred into the pair based on the below calculation (lines 250-251):
uint256 amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint256 amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
The issue is that balance0 and balance1 are inclusive of swap fees, but reserve0 and reserve1 are not. Let’s say the attacker chooses amount0Out = totalLaunchpadFee0 and does not transfer any tokens into the contract prior to calling swap. In this case balance0 (the balance measured after transferring amount0Out to the attacker) is just the same as reserves. Therefore:
amount0In = balance0 - (_reserve0 - amount0Out) = _reserve0 - (_reserve0 - totalLaunchpadFee0) = totalLaunchpadFee0
The contract incorrectly infers that the attacker has transferred tokens in when they haven’t transferred anything. To make it work, the attacker needs to reduce amount0Out by 0.3% so the k invariant is still satisfied when considering fees. The same principle applies for amount1 also.
After the attacker’s call to swap, the issue can be exploited again due to the fact that the reserves are just updated to be current balances minus the accrued swap fees (assume the attacker performs the attack within the same block as when the fees were accrued, so this way the accrued fees are not cleared). Therefore the attacker can repeatedly execute swap in this manner in one transaction and drain a large amount of funds from the protocol.
Additionally, the pair does not correctly implement the k invariant as we can see on lines 256-261:
uint256 balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint256 balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
if (balance0Adjusted.mul(balance1Adjusted) < uint256(_reserve0).mul(_reserve1).mul(1000 ** 2)) {
revert("UniswapV2: K");
}
This is because the adjusted balances are actually inflated by the inclusion of accrued swap fees in them.
Impact: theft of up to 100% of tokens held by the pair.
Recommended mitigation steps
When inferring the amount(s) transferred in by the user, subtract off the accrued launchpad fees so the amounts can be accurately measured. Also, you need to take away the accrued fees from the balances before checking the k invariant.
Essentially, the accrued launchpad fees are no longer part of the available liquidity of the protocol, and so this needs to be carefully considered.
View detailed Proof of Concept
[H-05] GTELaunchpadV2Pair permits minting LP tokens for free when there are non-zero accumulated launch pad fees
Submitted by hgrano, also found by 0rpse, 0xNaN, AasifUsmani, AvantGard, BowTiedOriole, codegpt, dimulski, gizzy, hezze, IvanAlexandur, nuthan2x, saraswati, serial-coder, Web3Vikings, and ZeroEx
launchpad/uniswap/GTELaunchpadV2Pair.sol #L187-L188
In GTELaunchpadV2Pair::mint, the amount0 and amount1 of tokens provided to the contract are calculated as the difference between current balances and reserves on lines 185-188:
uint256 balance0 = IERC20(token0).balanceOf(address(this));
uint256 balance1 = IERC20(token1).balanceOf(address(this));
uint256 amount0 = balance0.sub(_reserve0);
uint256 amount1 = balance1.sub(_reserve1);
The issue is that reserves are set in the _update function as the current balances subtract away any accumulated fees (lines 154-155):
reserve0 = _reserve0 = uint112(balance0) - totalLaunchpadFee0;
reserve1 = _reserve1 = uint112(balance1) - totalLaunchpadFee1;
Let’s assume a scenario where both totalLaunchpadFee0 and totalLaunchpadFee1 are non-zero and a user directly calls mint (without pre-transferring any tokens to the pair). In this case, mint will mistakenly calculate amount0 and amount1 to be the launchpad fees - the difference between current balances and reserves. Thereby the user will be able to mint LP tokens for free. They can repeat the call to mint an unlimited number of times - provided they make the calls within the same block as when the fees were received so that the launchpad fees don’t get cleared. They can then cash out the stolen assets using burn.
Impact: theft of user and protocol funds - this could end up draining all of the funds in the pair contract due to a large number of calls to the mint function.
Recommended mitigation steps
Subtract off the accumulated launch pad fees when calculating the amount transferred in by the minter:
--- a/contracts/launchpad/uniswap/GTELaunchpadV2Pair.sol
+++ b/contracts/launchpad/uniswap/GTELaunchpadV2Pair.sol
@@ -184,8 +184,8 @@ contract GTELaunchpadV2Pair is IUniswapV2Pair, IGTELaunchpadV2Pair, UniswapV2ERC
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
uint256 balance0 = IERC20(token0).balanceOf(address(this));
uint256 balance1 = IERC20(token1).balanceOf(address(this));
- uint256 amount0 = balance0.sub(_reserve0);
- uint256 amount1 = balance1.sub(_reserve1);
+ uint256 amount0 = balance0.sub(_reserve0).sub(accruedLaunchpadFee0);
+ uint256 amount1 = balance1.sub(_reserve1).sub(accruedLaunchpadFee1);
View detailed Proof of Concept
[H-06] Donations to Distributor with arbitrary quoteToken can be used to drain all quote rewards from distributor
Submitted by hgrano, also found by 0xNaN, anchabadze, Bigsam, c0pp3rscr3w3r, codegpt, cu5t0mpeo, deccs, dna-thug, edantes, hezze, lodelux, max10afternoon, nem0TheFinder, newspacexyz, Nexarion, nuthan2x, osok, PotEater, random1106, saraswati, serial-coder, TobechukwuAzogu, VAD37, VinciGearHead, Web3Vikings, Wolf_Kalp, and ZeroEx
launchpad/Distributor.sol #L106-L132
Distributor::addRewards fails to validate that the provided quoteAsset is a legitimate token. Consider the case where an attacker provides a legitimate launch token as the token0 parameter, token1 is a malicious contract and amount0 is zero:
function addRewards(address token0, address token1, uint128 amount0, uint128 amount1) external {
(address launchAsset, address quoteAsset, uint128 launchAssetAmount, uint128 quoteAssetAmount) =
(token0, token1, amount0, amount1);
RewardPoolData storage rs = RewardsTrackerStorage.getRewardPool(token0);
if (rs.quoteAsset == address(0)) {
// [...]
}
if (rs.totalShares == 0) revert NoSharesToIncentivize();
if (launchAssetAmount > 0) {
// [...]
}
if (quoteAssetAmount > 0) {
rs.addQuoteRewards(launchAsset, quoteAsset, quoteAssetAmount);
_increaseTotalPending(quoteAsset, quoteAssetAmount);
quoteAsset.safeTransferFrom(msg.sender, address(this), uint256(quoteAssetAmount));
}
The attacker can provide their own contract address for token1 as long as it implements a transferFrom function. The amount1 parameter they choose will cause an increase to the storage variable for the amount of quote rewards, but they will not contribute any actual reward tokens to the contract. Consequently, assuming the attacker holds some non-zero staking balance, they can then drain all the available quote tokens in the contract as the quote token rewards per share have been drastically over-inflated.
Impact: permanent loss of user funds as firstly they lose out on rewards. Secondly, the system becomes bricked for users which currently hold non-zero staking balances: they cannot transfer their tokens out due to the fact that LaunchToken triggers a reward distribution to the from account on transfers. This will fail now that there are insufficient quote tokens in the contract.
Recommended mitigation steps
Validate both the launch asset and quote asset are legitimate within addRewards.
View detailed Proof of Concept
[H-07] Total reward shares for token can reach zero after unlocking, causing GTELaunchpadV2Pair to be bricked
Submitted by hgrano, also found by 0rpse, 0x_DyDx, 0xIconart, 0xnija, 0xPhantom, 0xsai, 0xShitgem, 0xvd, AasifUsmani, agadzhalov, anchabadze, ARMoh, AvantGard, bigbear1229, c0pp3rscr3w3r, ChainSentry, chaos304, codegpt, dhank, dimulski, dray, Egbe, Ekene, eth_11, FalseGenius, hashbug, hezze, IvanAlexandur, jerry0422, joe23joe, johnyfwesh, lodelux, MadSisyphus, magiccentaur, mahdifa, max10afternoon, mightyraj2605, mrMorningstar, mustapha, nem0TheFinder, nuthan2x, prk0, random1106, RanjanSharma, ret2basic, Rhaydden, roccomania, romans, saikumar279, saraswati, serial-coder, Stormy, taticuvostru, udogodwin, v2110, VAD37, VinciGearHead, Web3Vikings, Wolf_Kalp, won, ZeroEx, and zzebra83
launchpad/Distributor.sol #L119
The totalShares variable stored for each launch token on the Distributor will eventually reach zero once all of the original stakers transfer out their tokens. If this happens once the token has been unlocked, then the rewards program which distributes rewards from the GTELaunchpadV2Pair to the Distributor continues regardless. We can see this is the case as the rewards program only ends if the total shares reaches zero prior to unlocking on LaunchToken.sol:147:
if (totalFeeShare == 0 && !unlocked) _endRewards();
Consider the case where totalShares goes to zero and any function which calls _update on the pair is executed. If there is any non-zero amount of accumulated launch pad fees (and some time has elapsed since the previous call), then the GTELaunchpadV2Pair will attempt to distribute the fees by calling Distributor::addRewards. Distributor.sol:119 will cause a revert as totalShares is zero:
if (rs.totalShares == 0) revert NoSharesToIncentivize();
Now the pair contract is bricked and cannot be used for swaps or adding/removing liquidity.
Recommended mitigation steps
It appears the condition on LaunchToken.sol:147 may have been coded accidentally with the wrong condition. It would make sense to change it to:
if (totalFeeShare == 0 && unlocked) _endRewards();
This way the rewards program will end post-unlocking if all stakers transfer their tokens out, preventing Distributor::addRewards being called by the pair.
View detailed Proof of Concept
[H-08] CREATE2 address of the uniswap pair used by LaunchPad does not match address of pair deployed by GTELaunchpadV2PairFactory
Submitted by hgrano, also found by 0xanony, 0xAsen, 0xPhantom, 0xsagetony, 0xsai, 0xShitgem, 0xterrah, AasifUsmani, agadzhalov, anchabadze, AvantGard, Ayomiposi233, bigbear1229, BlackAnon, boredpukar, c0pp3rscr3w3r, codegpt, deccs, dimulski, FalseGenius, gizzy, IvanAlexandur, JuggerNaut63, Legend, lodelux, lonelybones, lufP, MadSisyphus, magiccentaur, mahdifa, max10afternoon, nem0TheFinder, niffylord, nuthan2x, roccomania, saikumar279, serial-coder, SolidityScan, taticuvostru, udogodwin, v2110, Wolf_Kalp, ZeroEx, and zzebra83
launchpad/Launchpad.sol #L569-L587
Consider a situation in which a user has already called GTELaunchpadV2PairFactory::createPair for a quoteToken/launchToken pair, prior to the bonding curve becoming inactive. Then a user buys enough launch tokens to consume all the tokens in the bonding curve, thus we reach Launchpad.sol lines 483-489:
// Create or get the pair
try uniV2Factory.createPair(token, data.quote) returns (address p) {
pair = IUniswapV2Pair(p);
} catch {
// Do nothing, pair exists
// @todo its more gas but lets check pair exists and create if it doesn't.
// try catch in solidity is horrible and should be avoided
}
The call to createPair will revert because on GTELaunchpadV2PairFactory.sol:33, the pair creation reverts if it already exists. Therefore the pair variable is not assigned to the address of the newly created pair, but left as its original value which is computed by the Launchpad::pairFor function. This function determines the address on line 571:
pair = IUniswapV2Pair(
address(
uint160(
uint256(
keccak256(
abi.encodePacked(
hex"ff",
factory,
keccak256(abi.encodePacked(token0, token1)),
uniV2InitCodeHash // init code hash
)
)
)
)
)
);
The salt used only includes token0 and token1, rather than token0, token1, _launchpadLp and _launchpadFeeDistributor as used by the factory, resulting in a different address than what the factory uses.
Impact: the user attempting to buy more tokens than there are remaining in the bonding curve will have their transaction reverted on LaunchPad.sol:491 as the pair address will not be a contract address. The system will be permanently stuck in the bonding state as it won’t be possible to deploy a contract at the pair address.
There is also a second impact of this issue as it also occurs when the rewards program ends for a token. This is evident on LaunchToken.sol:147 which is executed if the token is currently locked but all buyers have since sold their tokens back to the Launchpad. Consequently, Launchpad::endRewards is called but this uses the incorrectly calculated pair address on Launchpad.sol:436:
IGTELaunchpadV2Pair pair = IGTELaunchpadV2Pair(address(pairFor(address(uniV2Factory), msg.sender, quote)));
distributor.endRewards(pair);
This will cause the transaction to revert as there will be no contract at the pair address.
Recommended mitigation steps
Re-factor the code so that the Launchpad::pairFor function returns an address matching that used by the factory.
View detailed Proof of Concept
[H-09] DOS of Launchpad Graduation via addLiquidity with 1 Wei donation
Submitted by gizzy, also found by 0xShitgem, IvanAlexandur, levantequarini, mrMorningstar, Pelz, pro_king, r1ver, and serial-coder
launchpad/Launchpad.sol #L500
An attacker can prevent Launchpad token graduation by front-running with a small donation to the target pair and calling sync(). This causes the router’s addLiquidity function to revert during graduation, effectively DOSing the graduation process .
Root Cause Analysis
The Problem
- Attacker donates small amount of quote token to target pair before graduation
- Calls sync(): This sets reserves to one-sided:
(reserveA=0, reserveB>0)
// 1. Attacker sees created token
// 2. sends donation + call sync
IERC20(quoteToken).transfer(targetPair, 1 wei);
IUniswapV2Pair(targetPair).sync();
- Router Fails:
addLiquiditycallsquote()which requires both reserves > 0 as per sponsor comment
yes for the router, you can assume it is identical to uniswap. The only difference between vanilla and gte univ2 (aside from solc version) would be the fee accrual in Pair.sol, and the factory needing to be customized so we could pass more information to the constructor of Pair https://code4rena.com/audits/2025-08-gte-perps-and-launchpad/inbox/16?replyUid=257
- Graduation DOS: Launchpad graduation reverts, preventing token launch completion
UniswapV2Library.quote() - Used by Router
function quote(uint amountA, uint reserveA, uint reserveB) internal pure returns (uint amountB) {
require(amountA > 0, 'UniswapV2Library: INSUFFICIENT_AMOUNT');
require(reserveA > 0 && reserveB > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY'); // ← FAILS HERE
amountB = amountA.mul(reserveB) / reserveA;
}
Launchpad._graduate() - Vulnerable Call
uniV2Router.addLiquidity({
tokenA: token,
tokenB: address(data.quote),
amountADesired: tokensToLock,
amountBDesired: quoteToLock,
amountAMin: 0, // ← Won't help, fails before this check
amountBMin: 0, // ← Won't help, fails before this check
to: address(launchpadLPVault),
deadline: block.timestamp
});
Impact
This will cause a temporary DOS on the graduation of that pair.
Recommended mitigation steps
function _graduate(address token) internal {
// ... existing code ...
// Check if pair has one-sided reserves
(uint112 reserve0, uint112 reserve1,) = pair.getReserves();
if (reserve0 == 0 || reserve1 == 0) {
// Bypass router, use direct pair.mint()
token.safeTransfer(address(pair), tokensToLock);
data.quote.safeTransfer(address(pair), quoteToLock);
pair.mint(address(launchpadLPVault));
} else {
// Normal router path
uniV2Router.addLiquidity({...});
}
}
View detailed Proof of Concept
[H-10] Protocol fails to charge fees from swap amount
Submitted by ifex445, also found by 0rpse, anchabadze, dimulski, hezze, IvanAlexandur, and nuthan2x
launchpad/uniswap/GTELaunchpadV2Pair.sol #L231
The GTE uniswap contract fails to charge fee for swaps and update balAdjusted by fees charged. This would allow users execute free swaps making LP providers lose rewards/fees for providing liquidity to the protocol.
function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external lock {
if (amount0Out == 0 && amount1Out == 0) revert("UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT");
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
if (amount0Out >= _reserve0 || amount1Out >= _reserve1) revert("UniswapV2: INSUFFICIENT_LIQUIDITY");
uint256 balance0;
uint256 balance1;
{
// scope for _token{0,1}, avoids stack too deep errors
address _token0 = token0;
address _token1 = token1;
if (to == _token0 || to == _token1) revert("UniswapV2: INVALID_TO");
@> if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
}
uint256 amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint256 amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
if (amount0In == 0 && amount1In == 0) revert("UniswapV2: INSUFFICIENT_INPUT_AMOUNT");
{
// scope for reserve{0,1}Adjusted and launchpadFee{0,1}, avoids stack too deep errors
@> uint256 balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
@> uint256 balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
As we can see in the pointer neither is the fee charge from the transfer of the swap or balance adjusted, the protocol goes ahead to include launchpad fees without backed transfer. This would break the balance of the pool as protocol assumes the fee was charged so it goes on to update pool variables with unbacked launchpad fees.
Impact
Loss of fees for GTE pool
Recommended mitigation steps
Charge fees for swaps.
View detailed Proof of Concept
Medium Risk Findings (10)
[M-16] Accumulated rewards per share can round to zero
Submitted by hgrano, also found by AvantGard, BenRai, HalfBloodPrince, KuwaTakushi, kwad, makarov, mbuba666, nuthan2x, prk0, ZeroEx, and zzebra83
launchpad/libraries/RewardsTracker.sol #L225
The accumulated rewards per share variables stored on the Distributor may round to zero, particularly for quote tokens with few decimals. The project has not specified the quote token, but it seems reasonable to expect USDC would be an option. Consider a scenario where USDC is the quote token and pendingQuoteRewards for the launched token is 1000 USDC. If a user calls Distributor::claim then the accumulated rewards per share will be calculated on RewardsTracker.sol:225:
accQuoteRewardsPerShare += ((self.pendingQuoteRewards * PRECISION_FACTOR) / uint128(totalShares));
The issue is that the PRECISION_FACTOR is only 1e12 so the numerator above is 1000e6 * 1e12 = 1000e18. Therefore if totalShares > 1000e18, zero will be added to accQuoteRewardsPerShare.
This scenario is not unlikely if using USDC because (1) totalShares is likely to be in the hundreds of millions of ether (based on the BONDING_SUPPLY which is hard coded as 800_000_000 ether) and (2) USDC earnings from the uniswap pair which add to the rewards will be small per each token swap (i.e. pendingQuoteRewards may be quite small).
Impact: users miss out on rewards and the funds are stuck in the Distributor contract.
Recommended mitigation steps
Consider increasing the PRECISION_FACTOR appropriately to handle this scenario.
View detailed Proof of Concept
[M-17] LaunchToken transfers cause staking rewards to be lost to the LaunchPad
Submitted by hgrano, also found by 0rpse, 0xanony, 0xAsen, 0xAura, 0xIconart, 0xMilenov, 0xnija, 0xsagetony, 0xsai, 0xvd, air_0x, AvantGard, Ayomiposi233, Bale, BenRai, boredpukar, c0pp3rscr3w3r, ChainSentry, codegpt, deccs, derastephh, dray, dreamcoder, Egbe, Ekene, emmac002, eth_11, gizzy, HalfBloodPrince, hashbug, hecker_trieu_tien, hezze, Idealz-hacks, IvanAlexandur, jerry0422, JuggerNaut63, KuwaTakushi, Ky0toFu, levantequarini, lodelux, lufP, max10afternoon, mightyraj2605, mrMorningstar, mzfr, nem0TheFinder, Nexarion, niffylord, nonso72, nuthan2x, odeili, osok, prk0, r1ver, ret2basic, Rhaydden, roccomania, serial-coder, SeveritySquad, solhhj, The_Amazing_One, Tofu, Tough, udogodwin, unnamed, veerendravamshi, VinciGearHead, Wolf_Kalp, won, yeahChibyke, ZeroEx, and zzebra83
During LaunchToken transfers the Distributor mistakenly distributes rewards to the msg.sender (which is the LaunchPad) rather than the token holder. Consider this code execution path assuming the token is not unlocked yet:
LaunchToken::transferFromis called.LaunchToken::_beforeTokenTransferhook is executed which callsincreaseStakeon theLaunchPadcontract for thetoaccount.LaunchPadcontract will then callincreaseStakeon theDistributor.- Assuming the
toaccount has pending earnings, theDistributorthen distributes earnings to the account on lines 170 and 175 of Distributor.sol, however the transfer is done to themsg.sender- theLaunchPad- rather than thetoaccount.
Impact: partial or complete loss of staking earnings for users. As shown in the attached PoC, all it takes is for a user to send some LaunchTokens to another user and the recipient loses all of the earnings they would otherwise be entitled to.
Recommended mitigation steps
Modify lines 170 and 175 of Distributor.sol so msg.sender is replaced with the correct account.
View detailed Proof of Concept
[M-18] Pair pre-creation disables Launchpad rewards hooks leading to no fees accrued or distributed
Submitted by 0xAsen, also found by 0rpse, 0x_DyDx, 0xanony, 0xMilenov, 0xNaN, 0xsai, aestheticbhai, Almanax, anchabadze, AvantGard, Bale, ChainSentry, codegpt, deccs, dimulski, EtherEngineer, gesha17, gizzy, jesjupyter, JuggerNaut63, lodelux, magbeans9, magiccentaur, metaBug, nonso72, PotEater, princekay, prk0, ret2basic, romans, saraswati, serial-coder, Stormy, Tofu, tralalelotralala, udogodwin, Web3Vikings, and ZeroEx
launchpad/uniswap/GTELaunchpadV2PairFactory.sol #L36-L37
The factory initializes Launchpad fee hooks (launchpadLp, launchpadFeeDistributor) only when createPair(tokenA, tokenB) is called by the Launchpad.
If anyone else creates the (token, quote) pair first, the factory initializes the pair with both hooks set to zero and locks it in getPair.
At graduation, liquidity is necessarily added to this canonical pair.
In the pair contract, fee accrual and forwarding to the Distributor are gated on launchpadFeeDistributor > 0, so no rewards ever accrue or distribute for that market, permanently.
Intended vs. actual logic
- Intended: After graduation, the canonical pair accrues a Launchpad fee share and forwards it to the
Distributorfor stakers. - Actual: If the pair was pre-created by a non-Launchpad caller, the hooks are zero; the canonical market never accrues or forwards rewards.
Preconditions
- Time window: any time before graduation.
- Attacker only needs to call
factory.createPair(token, quote). No token transfers / approvals required (pair creation deploys an empty pair; adding liquidity happens later). - Launchpad cannot recreate the pair later because
getPairis already set (PAIR_EXISTS).
Root cause
(1) Caller-conditioned initialization in the factory
// GTELaunchpadV2PairFactory.createPair(...)
(address _lp, address _dist) =
msg.sender == launchpad ? (launchpadLp, launchpadFeeDistributor) : (address(0), address(0));
bytes32 salt = keccak256(abi.encodePacked(token0, token1, _lp, _dist));
IUniswapV2Pair(pair).initialize(token0, token1, _lp, _dist);
getPair[token0][token1] = pair; // canonical
A non-Launchpad caller produces a pair permanently initialized with _lp = 0, _dist = 0, and locks it as canonical.
(2) Hard gating in the pair
// GTELaunchpadV2Pair.swap(...)
( uint112 fee0, uint112 fee1 ) =
launchpadFeeDistributor > address(0) && rewardsPoolActive > 0
? _getLaunchpadFees(amount0In, amount1In)
: (uint112(0), uint112(0));
// GTELaunchpadV2Pair._update(...)
if (launchpadFeeDistributor > address(0)) {
if (totalLaunchpadFee0 | totalLaunchpadFee1 > 0) {
delete accruedLaunchpadFee0;
delete accruedLaunchpadFee1;
_distributeLaunchpadFees(totalLaunchpadFee0, totalLaunchpadFee1);
}
}
With launchpadFeeDistributor == 0, the pair never computes Launchpad fees and never calls _distributeLaunchpadFees.
Impact
- Permanent reward suppression: All swaps in the market generate zero Launchpad rewards forever.
- Trustless & cheap: Single pre-creation tx, no approvals, no liquidity needed.
- Economic harm: Eliminates long-term reward stream to stakers (unbounded value depending on trading volume).
Scenario-based PoC
- Pre-create pair from a non-Launchpad EOA ->
factory.getPair(token, quote)points to the attacker-created pair. - Querying
launchpadLp()/launchpadFeeDistributor()either reverts (vanilla pair, no hooks) or returns0x0(GTE pair created by non-Launchpad). - Attempting to re-create the pair from the Launchpad reverts with
PAIR_EXISTS.
This is what the Foundry test test_submissionValidity() in the next section demonstrates.
Recommended mitigation steps
Restrict createPair for Launchpad markets to Launchpad only or always pass real launchpadLp and launchpadFeeDistributor to initialize(...) regardless of caller.
View detailed Proof of Concept
[M-19] Unsynchronized LaunchpadLPVault address leading to Loss of fee to Stakers of new launchPad token
Submitted by gizzy, also found by 0xMilenov, AvantGard, Bale, and nem0TheFinder
launchpad/Launchpad.sol #L414
The Launchpad contract can update its launchpadLPVault address, but the GTELaunchpadV2PairFactory has an immutable launchpadLp field. This creates a desynchronization where new pairs created after vault update reference the old vault for fee calculations while LP tokens go to the new vault, resulting in zero fee accrual for all new pairs.
Root Cause Analysis
The Problem
- Immutable Factory Settings:
GTELaunchpadV2PairFactory.launchpadLpis immutable and set during deployment - Mutable Launchpad Settings:
Launchpad.launchpadLPVaultcan be updated viaupdateLaunchpadLPVault() - Pair Fee Calculation: Pairs use their stored
launchpadLp(from factory) to calculate fee shares - Result: After vault update, new pairs reference the old vault but LP tokens go to the new vault during adding of liquidity, creating a mismatch
GTELaunchpadV2PairFactory.sol:9-10
address immutable launchpadLp;
address immutable launchpadFeeDistributor;
GTELaunchpadV2PairFactory.sol:36-37
(address _launchpadLp, address _launchpadFeeDistributor) =
msg.sender == launchpad ? (launchpadLp, launchpadFeeDistributor) : (address(0), address(0));
GTELaunchpadV2Pair.sol:282-285
uint256 launchpadLpBal = this.balanceOf(launchpadLp) + MINIMUM_LIQUIDITY;
if (amount0In > 0) fee0 = uint112(amount0In.mul(REWARDS_FEE_SHARE).mul(launchpadLpBal) / (totalLpBal * 1000));
if (amount1In > 0) fee1 = uint112(amount1In.mul(REWARDS_FEE_SHARE).mul(launchpadLpBal) / (totalLpBal * 1000));
sends lp to the updated launchpadLPVault
Launchpad.sol:414-416
function updateLaunchpadLPVault(address newLaunchpadLPVault) external onlyOwner {
launchpadLPVault = LaunchpadLPVault(newLaunchpadLPVault);
}
function _createPairAndSwapRemaining(
address token,
IUniswapV2Pair pair,
LaunchData memory data,
uint256 remainingBase,
uint256 remainingQuote,
address recipient
) internal returns (uint256 additionalQuoteUsed) {
//rest of the code
uniV2Router.addLiquidity({
tokenA: token,
tokenB: address(data.quote),
amountADesired: tokensToLock,
amountBDesired: quoteToLock,
amountAMin: 0,
amountBMin: 0,
to: address(launchpadLPVault),
deadline: block.timestamp
});
//rest of the code
}
Impact Analysis
- Zero fee accrual after vault update (100% loss of launchpad rewards) for new pair
- Breaks Protocol Accounting
Recommended mitigation steps
// In Launchpad.sol
function updateLaunchpadLPVault(address newLaunchpadLPVault) external onlyOwner {
launchpadLPVault = LaunchpadLPVault(newLaunchpadLPVault);
// Update factory settings
if (address(uniV2Factory) != address(0)) {
GTELaunchpadV2PairFactory(address(uniV2Factory)).updateLaunchpadSettings(
address(launchpadLPVault),
address(distributor)
);
}
}
View detailed Proof of Concept
[M-20] Price Accumulators Overflow in GTELaunchpadV2Pair contract Causes AMM-wide DoS
Submitted by AasifUsmani, also found by 0rpse, 0xanony, 0xIconart, dimulski, djshan_eden, johnyfwesh, KonstantinVelev, nonso72, prk0, and ret2basic
launchpad/uniswap/GTELaunchpadV2Pair.sol#L136-L137launchpad/uniswap/GTELaunchpadV2Pair.sol#L2
The GTE AMM is a Uniswap V2 fork but uses Solidity >0.8.0. Solidity ≥0.8 introduces built-in overflow/underflow checks, which revert instead of wrapping around.
In Uniswap V2, price accumulators (price0CumulativeLast, price1CumulativeLast) are explicitly designed to overflow naturally. This is both expected and documented behavior: cumulative prices are uint256 counters that wrap around over time, and TWAP calculations rely on differences in values, not absolute numbers.
In GTE’s implementation of GTELaunchpadV2Pair, because the accumulators are updated under Solidity >0.8.0 checked arithmetic, once they approach uint256.max, the addition will revert instead of overflowing. This causes:
- Permanent DoS of
_update(). - All liquidity mint/burn operations break.
- TWAP oracle updates fail, making the AMM price feed unusable.
- Eventually, the entire AMM grinds to a halt.
Both Uniswap V2 docs and Rareskills blogs explicitly mentions to implement the price accumulators inside the unchecked blocks to let the calculation overflow and prevent DoS.
Here is the Rareskills’s blog about why to use unchecked blocks with price accumulators:
- https://rareskills.io/post/build-your-own-uniswap
- https://rareskills.io/post/twap-uniswap-v2#uniswap-v2-does-not-store-lookback-or-the-denominator
This is what’s mentioned in Rareskills blog:
But this is an important point you should always remember price0CumulativeLast and price1CumulativeLast are only updated on lines 79 and 80 in the code above (orange circle), and they can only increase until they overflow. There is no mechanism make them “go down.” They always increase with every call to
_update. This means they accumulate prices ever since the pool is launched, which could be a very long time.
This explains why it is mandatory to use unchecked blocks while calculating price accumulators.
Recommended mitigation steps
To mitigate this critical bug, just wrap the price accumulators in unchecked block so that the calculation will skip the overflow/underflow checks:-
function _update(
uint256 balance0,
uint256 balance1,
uint112 _reserve0,
uint112 _reserve1,
uint112 newLaunchpadFee0,
uint112 newLaunchpadFee1
) private {
//----- Remaining logic -----
unchecked{
price0CumulativeLast += uint256(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
price1CumulativeLast += uint256(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
}
//----- Remaining logic -----
View detailed Proof of Concept
[M-21] Bypass of recipient check allows pre-seeding the real pair and manipulating initial AMM price
Submitted by 0xMilenov, also found by 0xsagetony, Bale, codegpt, dimulski, nem0TheFinder, nuthan2x, roccomania, romans, and Web3Vikings
launchpad/Launchpad.sol#L800-L842launchpad/uniswap/GTELaunchpadV2PairFactory.sol#L43-L82
The Launchpad system bonds a new LaunchToken against a quote asset on a bonding curve. Upon graduation, the Launchpad adds initial liquidity to a Uniswap V2 pair and locks the LP to a vault so that subsequent trading can begin. To prevent attackers from donating tokens to the AMM during bonding (which would set the initial AMM price), Launchpad attempts to block buys that send base tokens directly to the AMM pair by checking the recipient against the computed pair address.
Core actors and responsibilities:
Launchpad.sol: runs the bonding curve lifecycle (buy(),sell()), creates pairs on graduation, and guards against donations during bonding via_assertValidRecipient(). It also callsendRewards().GTELaunchpadV2PairFactory: deploysGTELaunchpadV2Pairand embeds bothlaunchpadLp(vault) andlaunchpadFeeDistributorinto the CREATE2 salt and into pair initialization.GTELaunchpadV2Pair: the AMM pair that accrues protocol fees to the distributor, parameterized via the factory.DistributorandLaunchpadLPVault: receive accrued rewards and custody of initial LP respectively.
Root cause - Launchpad.pairFor() computes a pair address using the standard Uniswap formula but only hashes (token0, token1) + initCodeHash:
pair = IUniswapV2Pair(
address(
uint160(
uint256(
keccak256(
abi.encodePacked(
hex"ff",
factory,
keccak256(abi.encodePacked(token0, token1)), // <-- here we are missing lp and distributor
uniV2InitCodeHash // init code hash
)
)
)
)
)
);
The custom factory’s CREATE2 salt additionally includes launchpadLp and launchpadFeeDistributor. The Launchpad’s computed address therefore diverges from the factory-deployed pair address:
bytes32 salt = keccak256(
abi.encodePacked(
token0,
token1,
_launchpadLp, // <-- here
_launchpadFeeDistributor // <-- here
)
);
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
The donation guard _assertValidRecipient() compares recipient to this wrong address, so passing the real pair as recipient will not trip the check and donations are allowed during bonding.
function _assertValidRecipient(
address recipient,
address baseToken
) internal view returns (IUniswapV2Pair pair) {
pair = pairFor(
address(uniV2Factory),
baseToken,
_launches[baseToken].quote
);
if (address(pair) == recipient) revert InvalidRecipient();
}
[!NOTE] The previous mitigation attempted to forbid donations by checking
recipient != pairFor(...)during bonding. BecausepairFor()is wrong for this custom factory, the check can be bypassed by supplying the real pair address as therecipient.
Detailed consequences:
-
Root cause - pairFor function - wrong address derivation:
- In
pairFor(), the salt is computed as:keccak256(abi.encodePacked(token0, token1)) - The custom factory actually uses:
keccak256(abi.encodePacked(token0, token1, launchpadLp, launchpadFeeDistributor)) - Result: Launchpad’s locally computed address differs from the factory’s actual pair, making any downstream logic relying on
pairFor()incorrect.
- In
-
First vulnerable place - recipient check bypass in
_assertValidRecipient():- The donation guard compares
recipienttopairFor(factory, baseToken, quote). BecausepairFor()is wrong, an attacker can pass the real pair asrecipient, and the equality check will be false, allowing base tokens to be sent to the real pair during bonding. - This enables pre-seeding reserves before graduation, setting initial AMM price arbitrarily and breaking the assumption that Launchpad controls the initial price by calling
addLiquidity()after bonding. buy()invokes_assertValidRecipient()to prevent donations. Due to the first vulnerable place,buy()succeeds withrecipient=realPair, transferring purchasedLaunchTokenstraight into the AMM pair during bonding.- With sufficient preparation, the attacker can then add quote tokens and mint LP to lock in manipulated reserves/price before launchpad’s graduation adds its intended liquidity.
- Or with other words, the fix of the critical vulnerability from the previous audit, is not well implemented
- The donation guard compares
-
Second vulnerable place - rewards finalization misdirection in
endRewards():-
endRewards()uses the same miscomputed address to target the pair when calling into the distributor/pair to end accrual. Post-graduation, this can result in a silent DoS (targeting an EOA/no-code) or acting on an unrelated pair if an address collision exists, leaving the real market’s rewards active or incorrectly affected.function endRewards() external onlyLaunchAsset { address quote = _launches[msg.sender].quote; IGTELaunchpadV2Pair pair = IGTELaunchpadV2Pair( address(pairFor(address(uniV2Factory), msg.sender, quote)) );
distributor.endRewards(pair); }
-
-
Third vulnerable place - post-graduation skim path and ops on wrong address:
-
In
_createPairAndSwapRemaining(), Launchpad may callpair.skim(owner()). If the pair already exists and Launchpad’spairFor()is wrong, it will operate on the wrong address.IUniswapV2Pair pair = _assertValidRecipient( buyData.recipient, buyData.token ); ... ... function _graduate( BuyData calldata buyData, IUniswapV2Pair pair, LaunchData memory data, uint256 amountOutBaseActual, uint256 amountInQuote ) internal returns (uint256 finalAmountOutBaseActual, uint256 finalAmountInQuote) { LaunchToken(buyData.token).unlock(); _launches[buyData.token].active = false; emit BondingLocked(buyData.token, pair, LaunchpadEventNonce.inc());
uint256 additionalQuote = _createPairAndSwapRemaining({ token: buyData.token, pair: pair, … …
-
function _createPairAndSwapRemaining( address token, IUniswapV2Pair pair, LaunchData memory data, uint256 remainingBase, uint256 remainingQuote, address recipient ) internal returns (uint256 additionalQuoteUsed) { … … pair.skim(owner());
Highest-impact scenario (replicates the old report’s flow with current code):
- During bonding, a user buys base and sets the `recipient` to the real Uniswap V2 pair address. Since `_assertValidRecipient()` compares against the wrong address, the buy succeeds and base tokens are transferred into the AMM pair.
- The attacker adds quote liquidity and mints LP to fix the reserves ratio and effectively set the initial AMM price.
- When bonding completes and Launchpad calls `addLiquidity()`, it will no longer be able to inject its intended liquidity ratio; instead, Uniswap’s `_addLiquidity()` uses current reserves to compute optimal amounts, preserving the attacker’s chosen price. The attacker can then immediately sell launch tokens at favorable prices to extract excess quote tokens.
### Recommended mitigation steps
Update `pairFor()` to replicate the factory salt and avoid mismatch:
- Build salt as `keccak256(abi.encodePacked(token0, token1, address(launchpadLPVault), address(distributor)))`.
- Keep `uniV2InitCodeHash` in sync with the actual pair bytecode in use. Prefer immutable configuration or rotate router/factory alongside the hash.
- Still prefer `getPair()` for safety and simplicity.
[View detailed Proof of Concept](https://code4rena.com/audits/2025-08-gte-perps-and-launchpad/submissions/F-59)
***
## [[M-22] User can prevent anyone from receiving rewards](https://code4rena.com/audits/2025-08-gte-perps-and-launchpad/submissions/F-62)
*Submitted by [mrMorningstar](https://code4rena.com/audits/2025-08-gte-perps-and-launchpad/submissions/S-320), also found by [agadzhalov](https://code4rena.com/audits/2025-08-gte-perps-and-launchpad/submissions/S-1280), [BowTiedOriole](https://code4rena.com/audits/2025-08-gte-perps-and-launchpad/submissions/S-1402), [gesha17](https://code4rena.com/audits/2025-08-gte-perps-and-launchpad/submissions/S-745), [IvanAlexandur](https://code4rena.com/audits/2025-08-gte-perps-and-launchpad/submissions/S-872), [kimnoic](https://code4rena.com/audits/2025-08-gte-perps-and-launchpad/submissions/S-937), [mahdifa](https://code4rena.com/audits/2025-08-gte-perps-and-launchpad/submissions/S-265), and [Neon2835](https://code4rena.com/audits/2025-08-gte-perps-and-launchpad/submissions/S-1363)*
`launchpad/LaunchToken.sol` [#L134](https://github.com/code-423n4/2025-08-gte-perps/blame/f43e1eedb65e7e0327cfaf4d7608a37d85d2fae7/contracts/launchpad/LaunchToken.sol#L134)
When user buys base tokens (via [buy](https://github.com/code-423n4/2025-08-gte-perps/blob/f43e1eedb65e7e0327cfaf4d7608a37d85d2fae7/contracts/launchpad/Launchpad.sol#L272)) they become eligible to earn rewards which are distributed over time.
The trigger for ending rewards is in `LaunchToken.sol`:
```js
function _decreaseFeeShares(address account, uint256 amount) internal {
uint256 share = bondingShare[account];
if (share == 0 || account == address(0)) return;
amount = amount > share ? share : amount;
emit FeeShareDecreased(account, amount, _incEventNonce());
unchecked {
totalFeeShare -= amount;
bondingShare[account] -= amount;
}
if (totalFeeShare == 0 && !unlocked) _endRewards();
ILaunchpad(launchpad).decreaseStake(account, uint96(amount));
}
/// @dev Hook to end rewards program for this base token if no more pre-bonding shares exist
function _endRewards() internal {
ILaunchpad(launchpad).endRewards();
emit FeeShareConcluded(block.timestamp, _incEventNonce());
}
As we can see rewards would be ended in case all base tokens are sold before graduation happens.
When graduation happens, protocol creates token pair (if it is not already created) and adds remaining liquidity to it so they can be swapped via AMM where with each swap rewards are accrued.
This can be exploited and user would prevent accrual of rewards and fees.
Malicious user creates pair in GTELaunchpadV2PairFactory via createPair. As we can see function is permission less anyone can create pair. The protocol accounted that in _createPairAndSwapRemaining when graduating:
.
.
.
// Create or get the pair
try uniV2Factory.createPair(token, data.quote) returns (address p) {
pair = IUniswapV2Pair(p);
} catch {
// Do nothing, pair exists
// @todo its more gas but lets check pair exists and create if it doest.
// try catch in solidity is horrible and should be avoided
}
.
.
.
It will try to create pair and if pair is already created it will use that.
Then user (we can assume first buyer) buys base tokens via buy and then immediately sells them via sell.
This will trigger the end rewards in LaunchToken because total fee shares would be 0 and unlocked is still false because bonding period is not over.
So eventually endRewardsAccrual would be invoked for the pair and rewards would stop accruing before they actually begin accruing after graduation:
function endRewardsAccrual() external {
if (msg.sender != launchpadFeeDistributor) revert("GTEUniV2: FORBIDDEN");
// There are no more shares, so prevent distribution and accrual of any remaining rewards
delete accruedLaunchpadFee0;
delete accruedLaunchpadFee1;
delete rewardsPoolActive;
_update(
IERC20(token0).balanceOf(address(this)),
IERC20(token1).balanceOf(address(this)),
reserve0,
reserve1,
uint112(0),
uint112(0)
);
emit RewardsPoolDeactivated();
}
As we can see rewardsPoolActive will go from 1 to 0 when reward accrual is ended.
The rewards are accrued via swap which can be done after graduation only and when rewardsPoolActive is not 0:
.
.
.
(uint112 launchpadFee0, uint112 launchpadFee1) = launchpadFeeDistributor > address(0)
&& rewardsPoolActive > 0 ? _getLaunchpadFees(amount0In, amount1In) : (uint112(0), uint112(0));
.
.
.
So malicious user effectively prevented accruing rewards and with no cost basically (only gas fees).
Here are the simplified steps to recap this attack:
- User create pair
- User buys and then immediately sells base tokens
- End rewards is triggered in
LaunchToken - From distributor on pair contract
endRewardsAccrualis invoked so protocol stops accruing rewards (before accrual actually begins after graduation) - After some time, graduation occurs and users start swapping without protocol collecting fees for rewards as it should
Impact
Malicious user can prevent rewards accrual.
The attack is simple, repetitive and cheap to execute so the likelihood to happen is HIGH.
Recommended mitigation steps
If possible design so that ending of rewards can be done only after graduation happens as this will prevent the exploit.
Also maybe add functionality where trusted address or admin can enable and disable rewards accruing.
View detailed Proof of Concept
[M-23] Rounding down in Quote calculation allows underpriced LaunchToken purchases by Malicious user, compounding protocol loss over multiple buys.
Submitted by Ayomiposi233, also found by EtherEngineer, harry, hgrano, lioblaze, Manga, oracle-script, ret2basic, taticuvostru, and VAD37
launchpad/Launchpad.sol#L272-L305launchpad/BondingCurves/SimpleBondingCurve.sol#L98-L105launchpad/BondingCurves/SimpleBondingCurve.sol#L202-L210
The _getQuoteAmount function in the SimpleBondingCurve contract uses integer division for calculating the quote amount required for a given base amount during token buys on the Launchpad.
function _getQuoteAmount(uint256 baseAmount, uint256 quoteReserve, uint256 baseReserve, bool isBuy)
internal
pure
returns (uint256 quoteAmount)
{
uint256 baseReserveAfter = isBuy ? baseReserve - baseAmount : baseReserve + baseAmount;
return (quoteReserve * baseAmount) / baseReserveAfter;
}
specifically, the last line of the function return (quoteReserve * baseAmount) / baseReserveAfter; will truncate any fractional remainder towards zero due to Solidity’s integer arithmetic. This results in a floored quote value, allowing users to pay less than the mathematically ideal amount for tokens.
Minor division issues might be negligible in integer-based systems, but this particularly flawed implementation poses too much of a risk, as a Malicious user can make targeted small buys that exploit this error, underpaying for purchased LaunchAssets with the following impacts:
- The underpayment directly reduces the quoteReserve added to the curve.
- Subsequent calculations use this understated reserve, causing the error to compound: Future buys are also underpriced relative to the ideal constant product invariant.
- Attackers can strategically select baseAmount values (as long as they meet the minimum) to maximize the truncation effect, repeatedly buying at a discount until the curve is drained or the bonding phase ends.
Over relatively short time, this can lead to significant quote token losses for the protocol, and it also advantages early exploiters at the expense of later participants. The issue is reproducible as long as the curve is active and the buys are calculated by the user, as shown in the PoC.
Recommended mitigation steps
This can be mitigated by using a higher minimumBase amount, however, this is not a complete solution as the attacker can still buy with a calculated base amount such that the quote returned is rounded down. only much larger buys wont be feasible as numerator will be much larger compared to the denominator hence the rounding down effect will be less pronounced.
A better solution would be to implement a mechanism to handle the rounding error, such as a precision factor or fixed point arithmetic library that can handle decimals more accurately.
View detailed Proof of Concept
[M-24] Launchpad slippage is not enforced properly during token graduation
Submitted by chaos304, also found by 0rpse, bigbear1229, deividrobinson, Ekene, hgrano, HighKingMargo, jesjupyter, Kaysoft, la-arana-inteligente, Legend, NexusAudits, niffylord, nuthan2x, r1ver, randomx, taticuvostru, udogodwin, VinciGearHead, and Web3Vikings
launchpad/Launchpad.sol #L287
When a user wants to buy tokens from the launchpad, they specify the number of base tokens they want to buy and the max amount of quote tokens they want to spend as slippage. The contract then proceeds to buy the base tokens from the curve and checks whether the quote tokens paid is higher than the max amount of quote tokens specified by the user or not and reverts the transaction if so.
function buy(BuyData calldata buyData)
external
nonReentrant
onlyBondingActive(buyData.token)
onlySenderOrOperator(buyData.account, SpotOperatorRoles.LAUNCHPAD_FILL)
returns (uint256 amountOutBaseActual, uint256 amountInQuote)
{
IUniswapV2Pair pair = _assertValidRecipient(buyData.recipient, buyData.token);
LaunchData memory data = _launches[buyData.token];
(amountOutBaseActual, data.active) = _checkGraduation(buyData.token, data, buyData.amountOutBase);
amountInQuote = data.curve.buy(buyData.token, amountOutBaseActual);
if (data.active && amountInQuote == 0) revert DustAttackInvalid();
@> if (amountInQuote > buyData.maxAmountInQuote) revert SlippageToleranceExceeded();
buyData.token.safeTransfer(buyData.recipient, amountOutBaseActual);
address(data.quote).safeTransferFrom(buyData.account, address(this), amountInQuote);
_emitSwapEvent({
account: buyData.account,
token: buyData.token,
baseAmount: amountOutBaseActual,
quoteAmount: amountInQuote,
isBuy: true,
curve: data.curve
});
// If graduated, handle AMM setup and remaining swap
if (!data.active) {
(amountOutBaseActual, amountInQuote) = _graduate(buyData, pair, data, amountOutBaseActual, amountInQuote);
}
}
This slippage protection mechanism is ineffective when a purchase order exhausts the bonding curve’s supply and triggers the “graduation” to a secondary market (AMM pair). The contract checks the slippage against the cost of tokens bought from the bonding curve only, not the total intended purchase. This allows a transaction to succeed even if the user receives a fraction of their desired tokens at an exorbitant effective price.
Recommended mitigation steps
Consider calculating and checking the price per token the user is willing to pay and the price per token bought instead.
View detailed Proof of Concept
[M-25] Bonding Shares Incorrectly Reduced/unstaked on Transfer in launchToken.
Submitted by Web3Vikings, also found by AvantGard, VAD37, and VinciGearHead
launchpad/LaunchToken.sol#L114launchpad/LaunchToken.sol#L134-L150
Component: contracts/launchpad/LaunchToken.sol - _decreaseFeeShares() function
When users transfer LaunchTokens, the _decreaseFeeShares() function incorrectly reduces bonding shares even when the sender still holds their original (first generation) tokens purchased from the launchpad. This breaks the fee-sharing mechanism for legitimate token holders.
Vulnerable Code Location
// LaunchToken.sol lines 134-151
function _decreaseFeeShares(address account, uint256 amount) internal {
uint256 share = bondingShare[account];
if (share == 0 || account == address(0)) return;
amount = amount > share ? share : amount; //Reduces shares by transfer amount, not considering remaining tokens
emit FeeShareDecreased(account, amount, _incEventNonce());
unchecked {
totalFeeShare -= amount;
bondingShare[account] -= amount; //Incorrectly reduces bonding shares
}
if (totalFeeShare == 0 && !unlocked) _endRewards();
ILaunchpad(launchpad).decreaseStake(account, uint96(amount));
}
Attack Vector / Bug Flow
- UserA: Buys 400M tokens → gets 400M bonding shares
- UserB: Buys 400M tokens → gets 400M bonding shares -> {{ Token bonds(graduates) and thus is
unlocked}} - UserA: Transfers 400M tokens to UserB → UserA correctly loses all bonding shares
- UserB: Now has 800M tokens but still only 400M bonding shares
- UserB: Transfers the 400M tokens gotten from UserA to UserC → BUG: UserB loses ALL bonding shares
- Result: UserB still holds 400M original tokens but has 0 bonding shares and loses fee-sharing rights (unstake).
Recommended mitigation steps
The bonding share reduction logic should track which tokens are original vs. transferred:
// Option 1: Track original vs transferred tokens separately
mapping(address => uint256) public originalTokens;
mapping(address => uint256) public transferredTokens;
// Option 2: Only reduce bonding shares proportionally to original tokens transferred
function _decreaseFeeShares(address account, uint256 amount) internal {
uint256 share = bondingShare[account];
if (share == 0 || account == address(0)) return;
// Only reduce shares if transferring original tokens, not received tokens
uint256 originalBalance = originalTokens[account];
uint256 shareReduction = amount > originalBalance ? share : (share * amount) / originalBalance;
// ... rest of function with shareReduction instead of amount
}
View detailed Proof of Concept
Low Risk and Informational Issues
For this audit, 28 QA reports were submitted by wardens compiling low risk and informational issues. The QA report highlighted below by Orhukl received the top score from the judge. 44 Low-severity findings were also submitted individually, and can be viewed here.
The following wardens also submitted QA reports: 0xbrett8571, 0xMilenov, 0xnija, Almanax, arjun16, ARMoh, codegpt, dhank, Ekene, foxb868, francoHacker, gu4rdian, home1344, hypna, K42, nachin, niffylord, princekay, ret2basic, Rhaydden, Riceee, surenyanoks, The_Amazing_One, Tofu, udogodwin, won, and yeahChibyke.
This QA report identifies low-risk and governance/centralization findings in the Launchpad smart contract system. The issues presented here do not pose direct threats to user funds but impact protocol functionality, user experience, and operational security.
[L-01] Staker Rewards Misdirected to Launchpad Contract
Impact: Loss of user rewards during stake/unstake operations
Location: Distributor.sol - increaseStake()/decreaseStake() functions
Description:
During stake and unstake operations, rewards that should go to users are incorrectly sent to the Launchpad contract, where they become trapped and inaccessible.
Root Cause:
In Distributor.sol, the increaseStake() and decreaseStake() functions incorrectly route rewards:
function increaseStake(address launchpad, address account, uint96 shares) external onlyLaunchpad {
(uint256 baseAmount, uint256 quoteAmount) = rs.stake(account, shares);
// BUG: sends rewards to msg.sender (Launchpad) instead of account
_distributeAssets(baseAmount, quoteAmount);
}
function _distributeAssets(uint256 baseAmount, uint256 quoteAmount) internal {
// Sends to msg.sender = Launchpad contract
if (baseAmount > 0) baseToken.safeTransfer(msg.sender, baseAmount);
if (quoteAmount > 0) quoteToken.safeTransfer(msg.sender, quoteAmount);
}
Impact Analysis:
- User rewards are deducted from accounting but sent to Launchpad
- Tokens become trapped in Launchpad contract
- Users lose legitimate reward payouts
- Creates inconsistency with
claimRewards()function which works correctly
Recommendation:
- Modify
_distributeAssets()to accept a recipient parameter - Pass the correct user account through the call chain
- Add forwarding logic in Launchpad functions or pay users directly
[L-02] Incorrect Pair Address Derivation Breaks Recipient Validation
Impact: Fund theft through recipient validation bypass
Location: Launchpad.sol - pairFor() and _assertValidRecipient()
Description:
The pairFor() function in Launchpad uses a different salt for address derivation than the actual pair factory, causing recipient validation to fail and enabling potential fund manipulation.
Root Cause: Address derivation mismatch between contracts:
Launchpad.pairFor():
salt = keccak256(abi.encodePacked(token0, token1))
GTELaunchpadV2PairFactory.createPair():
salt = keccak256(abi.encodePacked(token0, token1, launchpadLp, launchpadFeeDistributor))
Impact Analysis:
- Recipient validation uses wrong pair address for comparison
- Real pair address could be used as recipient, bypassing intended restrictions
- Potential for pre-funding attacks during bonding phase
- Reward system may use incorrect pair address
Recommendation:
- Synchronize salt calculation between Launchpad and Factory
- Add validation to ensure address derivation consistency
- Review and test pair address calculations thoroughly
[L-03] Potential DoS from Integer Overflow in Price Cumulative Calculations
Impact: Protocol functionality disruption in year 2106
Location: GTELaunchpadV2Pair.sol - _update() function
Description: The price cumulative calculations and timestamp operations may cause DoS due to integer overflow when using Solidity 0.8+ without unchecked blocks.
Root Cause:
uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired
// ...
price0CumulativeLast += uint256(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
price1CumulativeLast += uint256(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
Issues:
- Solidity 0.8+ has automatic overflow protection
- The comment indicates “overflow is desired” but doesn’t use
unchecked - Price cumulative calculations could revert instead of overflowing gracefully
- Will cause DoS in 2106 when
uint32timestamp overflows
Impact Analysis:
- Protocol becomes unusable when timestamp overflow occurs
- Price oracle functionality breaks
- Swap operations fail due to update function reverting
Recommendation:
Wrap overflow-intended operations in unchecked blocks:
uint32 timeElapsed;
unchecked {
timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired
}
if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
unchecked {
price0CumulativeLast += uint256(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
price1CumulativeLast += uint256(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
}
}
[L-04] Missing Deadline Protection in Buy Function
Impact: MEV vulnerability and transaction timing issues
Location: Launchpad.sol - buy() function
Description:
The buy() function lacks deadline parameter protection, making user transactions vulnerable to MEV (Maximal Extractable Value) attacks and allowing execution at potentially unfavorable times.
Recommendation:
Add a deadline parameter to the buy() function and validate it against block.timestamp:
function buy(address recipient, uint256 amount, uint256 deadline) external {
require(block.timestamp <= deadline, "Transaction expired");
// ... rest of function logic
}
[L-05] No Slippage Protection During Graduation
Impact: Unfavorable liquidity ratios during token graduation
Location: Launchpad.sol - _createPairAndSwapRemaining()
Description:
The addLiquidity() call during the critical graduation phase uses amountAMin=0 and amountBMin=0, providing no slippage protection. This can result in unfavorable liquidity ratios being established when the pair is created.
Impact Analysis:
- During high volatility periods, graduation could occur at suboptimal ratios
- Initial liquidity providers may receive unfavorable pool positions
- Price manipulation during graduation becomes easier
Recommendation: Implement minimum amount parameters based on current market conditions or allow configuration of slippage tolerance.
[L-06] Hardcoded Deadline in AMM Operations
Impact: Minimal transaction timing protection
Location: Launchpad.sol - AMM interaction functions
Description:
Both addLiquidity() and swapTokensForExactTokens() use block.timestamp or block.timestamp + 1 as deadlines, providing insufficient protection against transaction timing manipulation.
Issues:
block.timestampas deadline offers no protectionblock.timestamp + 1provides minimal protection (1 second)- Transactions can be delayed by miners/validators without meaningful constraints
Recommendation:
Use more reasonable deadline windows (e.g., block.timestamp + 300 for 5 minutes).
Additional Observations
Code Quality Improvements
- Event Emissions: Consider adding more comprehensive event emissions for better off-chain tracking
- Error Messages: Some functions could benefit from more descriptive error messages
- Documentation: Additional inline documentation would improve code maintainability
Testing Recommendations
- Edge Cases: Test graduation scenarios under various market conditions
- MEV Scenarios: Test buy/sell operations under MEV conditions
- Integration Testing: Comprehensive testing with actual AMM deployments
- Stress Testing: High-volume transaction scenarios
- Overflow Testing: Test price cumulative calculations near overflow boundaries
Conclusion
The identified low-risk findings primarily relate to reward distribution, address derivation consistency, integer overflow handling, and transaction timing protections. While these issues don’t pose immediate threats to user funds, addressing them would improve the protocol’s robustness and provide better user experience.
The most critical findings involve reward misdirection and potential DoS from overflow issues in price calculations. The centralization findings highlight the importance of clear governance structures and proper documentation of admin privileges.
Total Issues: (6 Low Risk)
Priority: Address L-01, L-02, and L-03 before mainnet deployment
Timeline: Recommended fixes within current development cycle
Disclosures
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.