GTE

GTE Perps and Launchpad
Findings & Analysis Report

2026-02-05

Table of contents

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:

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.

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

  • perps/types/Market.sol #L136
  • perps/types/CLOBLib.sol #L277
  • perps/types/Book.sol #L90

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.

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

  • perps/PerpManager.sol #L221
  • perps/types/CollateralManager.sol #L72

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;
    }

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

  • perps/types/Market.sol #L437
  • perps/types/Market.sol #L421

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 markPrice for 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.timestamp 10, a malicious user can submit a SELL order with a price of 3_991e18 that expires at block.timestamp 11(the next block).
  • Now at block.timestamp 11 the protocol admin calls the Market.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 the getImpactPriceTwap() 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.timestamp 11 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.

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.

Implement one of the following solutions:

  1. 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.
  2. 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 baseSpread snapshots 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:

  1. The intendedMargin decreases (since intendedMargin = currentNotional / newLeverage)
  2. The settleNewLeverage() function adjusts the margin balance to match the new intended margin
  3. 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 in setPositionLeverage().

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.

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

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:

  1. Margin Management Functions:

    • addMargin() - Allows adding margin to existing positions
    • removeMargin() - Allows removing margin from positions
    • setPositionLeverage() - Allows changing position leverage
  2. Collateral Management Functions:

    • deposit() - Allows depositing free collateral
    • withdraw() - Allows withdrawing free collateral
    • depositTo() - Allows depositing to other accounts
    • depositFromSpot() - Allows depositing from spot account
    • withdrawToSpot() - 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.

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

  • perps/types/CLOBLib.sol #L347
  • perps/types/CLOBLib.sol #L378

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.

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

  1. Alice places an order to buy 10 ETH at $2,000.
  2. Alice now decides to change the expiry while the order is still pending, but as of now the order hasn’t been filled at all.
  3. Before Alice’s amendLimitOrder transaction is mined, 9 ETH from the original order was now partially filled.
  4. Alice’s amendLimitOrder is processed and still assumes the original 10 ETH size, only modifying the expiry.

Resulting State

  1. 9 ETH already filled
  2. Remaining order incorrectly updated to 10 ETH (instead of 1 ETH)

Alice has unintentionally taken on almost double the intended financial exposure:

  1. Initial intended exposure: 10 ETH
  2. Actual exposure after race: 19 ETH

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.

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

Use one of the provide methods inside the solady ERC4626 contract:

  1. Decimal offset
  2. Virtual shares
  3. 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

  1. 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.
  2. Possible Incorrect margin/collateral accounting

    • Active positions will disappear from the assets list, leading to missed collateral contributions or skipped liabilities.
    • Zeroed positions will remain listed, disrupting accounting
  3. Liquidation safety risk

    • An undercollateralized account may avoid liquidation if an underwater position, causing asset is popped (cross margin).
  4. Possible Inaccessibility of Funds

    • Assets with active balances but missing from the list will become unreachable by user or system operations.

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 Δt results in ≈ N × funding
  • A single settlement over total N × Δt results 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.
  1. Add time-proportional scaling — scale per-interval funding by elapsed / baseInterval.
  2. Unit tests for path-independence — confirm that 1×10h = 10×1h accrual.
  3. 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 current maxOpenLeverage setting
  • 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:

  1. Orders are placed via ClearingHouse.placeOrder()Market.placeOrder()CLOBLib.placeOrder()
  2. When orders are filled, ClearingHouse._processTakerFill() processes the trade
  3. The leverage for a new position is determined in ClearingHouseLib._getPostiion() by calling Market.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:

  1. Legacy High Leverage: Users who previously set high leverage (e.g., 50x) can continue opening new positions at that leverage even after maxOpenLeverage is reduced (e.g., to 25x)
  2. Position Size Increases: Users can increase their position size at the old high leverage, effectively bypassing the reduced leverage limits
  3. Admin Intent Bypass: When administrators reduce maxOpenLeverage for 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.

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.

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:

  1. Perform swap(s) to ensure the accrued launchpad fees are non-zero.
  2. Mint LP tokens.
  3. 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.
  4. 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.

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.

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.

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.

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.

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.

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

  1. Attacker donates small amount of quote token to target pair before graduation
  2. 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();
  1. Router Fails: addLiquidity calls quote() 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

  1. 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.

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

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.

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

  • launchpad/Distributor.sol #L170
  • launchpad/Distributor.sol #L175

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:

  1. LaunchToken::transferFrom is called.
  2. LaunchToken::_beforeTokenTransfer hook is executed which calls increaseStake on the LaunchPad contract for the to account.
  3. LaunchPad contract will then call increaseStake on the Distributor.
  4. Assuming the to account has pending earnings, the Distributor then distributes earnings to the account on lines 170 and 175 of Distributor.sol, however the transfer is done to the msg.sender - the LaunchPad - rather than the to account.

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.

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 Distributor for 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 getPair is 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 returns 0x0 (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.

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

  1. Immutable Factory Settings: GTELaunchpadV2PairFactory.launchpadLp is immutable and set during deployment
  2. Mutable Launchpad Settings: Launchpad.launchpadLPVault can be updated via updateLaunchpadLPVault()
  3. Pair Fee Calculation: Pairs use their stored launchpadLp (from factory) to calculate fee shares
  4. 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
// 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-L137
  • launchpad/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:

  1. https://rareskills.io/post/build-your-own-uniswap
  2. 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.

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-L842
  • launchpad/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 calls endRewards().
  • GTELaunchpadV2PairFactory: deploys GTELaunchpadV2Pair and embeds both launchpadLp (vault) and launchpadFeeDistributor into the CREATE2 salt and into pair initialization.
  • GTELaunchpadV2Pair: the AMM pair that accrues protocol fees to the distributor, parameterized via the factory.
  • Distributor and LaunchpadLPVault: 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. Because pairFor() is wrong for this custom factory, the check can be bypassed by supplying the real pair address as the recipient.

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.
  • First vulnerable place - recipient check bypass in _assertValidRecipient():

    • The donation guard compares recipient to pairFor(factory, baseToken, quote). Because pairFor() is wrong, an attacker can pass the real pair as recipient, 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 with recipient=realPair, transferring purchased LaunchToken straight 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
  • 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 call pair.skim(owner()). If the pair already exists and Launchpad’s pairFor() 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:

  1. User create pair
  2. User buys and then immediately sells base tokens
  3. End rewards is triggered in LaunchToken
  4. From distributor on pair contract endRewardsAccrual is invoked so protocol stops accruing rewards (before accrual actually begins after graduation)
  5. 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.

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-L305
  • launchpad/BondingCurves/SimpleBondingCurve.sol #L98-L105
  • launchpad/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.

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.

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

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

  1. UserA: Buys 400M tokens → gets 400M bonding shares
  2. UserB: Buys 400M tokens → gets 400M bonding shares -> {{ Token bonds(graduates) and thus is unlocked }}
  3. UserA: Transfers 400M tokens to UserB → UserA correctly loses all bonding shares
  4. UserB: Now has 800M tokens but still only 400M bonding shares
  5. UserB: Transfers the 400M tokens gotten from UserA to UserC → BUG: UserB loses ALL bonding shares
  6. Result: UserB still holds 400M original tokens but has 0 bonding shares and loses fee-sharing rights (unstake).

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:

  1. Modify _distributeAssets() to accept a recipient parameter
  2. Pass the correct user account through the call chain
  3. 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:

  1. Recipient validation uses wrong pair address for comparison
  2. Real pair address could be used as recipient, bypassing intended restrictions
  3. Potential for pre-funding attacks during bonding phase
  4. Reward system may use incorrect pair address

Recommendation:

  1. Synchronize salt calculation between Launchpad and Factory
  2. Add validation to ensure address derivation consistency
  3. 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 uint32 timestamp 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.timestamp as deadline offers no protection
  • block.timestamp + 1 provides 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

  1. Event Emissions: Consider adding more comprehensive event emissions for better off-chain tracking
  2. Error Messages: Some functions could benefit from more descriptive error messages
  3. Documentation: Additional inline documentation would improve code maintainability

Testing Recommendations

  1. Edge Cases: Test graduation scenarios under various market conditions
  2. MEV Scenarios: Test buy/sell operations under MEV conditions
  3. Integration Testing: Comprehensive testing with actual AMM deployments
  4. Stress Testing: High-volume transaction scenarios
  5. 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.