Assessment reports>Avantis>Critical findings>Stop loss higher than ,openPrice, may cause fund loss
Category: Business Logic

Stop loss higher than openPrice may cause fund loss

Critical Severity
Critical Impact
High Likelihood

Description

The function openTrade is used to open a new market or limit trade. While creating a limit order, the openPrice could be set to any value. For a buy order, the value of stop loss should always be less than this openPrice due to the check t.sl < t.openPrice as shown below in openTrade:

function openTrade(
    ITradingStorage.Trade calldata t,
    IExecute.OpenLimitOrderType _type,
    uint _slippageP,
    bytes[] calldata priceUpdateData,
    uint _executionFee // In USDC. Optional for Limit orders
) external payable onlyWhitelist whenNotPaused {
    //...
    require(t.tp == 0 || (t.buy ? t.tp > t.openPrice : t.tp < t.openPrice),
        "WRONG_TP");
    require(t.sl == 0 || (t.buy ? t.sl < t.openPrice : t.sl > t.openPrice),
        "WRONG_SL");

    storageT.transferUSDC(msg.sender, address(storageT),
        t.positionSizeUSDC + _executionFee);

    if (_type != IExecute.OpenLimitOrderType.MARKET) {
        uint index = storageT.firstEmptyOpenLimitIndex(msg.sender,
            t.pairIndex);

        storageT.storeOpenLimitOrder(
            ITradingStorage.OpenLimitOrder(
                msg.sender,
                t.pairIndex,
                index,
                t.positionSizeUSDC,
                t.buy,
                t.leverage,
                t.tp,
                t.sl,
                t.openPrice,
                t.openPrice,
                block.number,
                _executionFee
            )
        );

        aggregator.executions().setOpenLimitOrderType(msg.sender,
            t.pairIndex, index, _type);

        emit OpenLimitPlaced(msg.sender, t.pairIndex, index,
            block.timestamp, _executionFee);
    } else //...
}

When a limit order is created, the maxPrice and the minPrice are set to the openPrice provided by user. If the order type is MOMENTUM, the executeLimitOrder could be called without errors as the require check a.price <= o.maxPrice in the executeLimitOpenOrderCallback callback function would pass as shown below:

function executeLimitOpenOrderCallback(AggregatorAnswer memory a)
external override onlyPriceAggregator {
    //...
    if (pairsStored.pairGroupIndex(o.pairIndex) == 0) {
        // crypto only
        (, uint priceAfterImpact) = pairInfos.getTradePriceImpact(
            _marketExecutionPrice(a.price, a.spreadP, o.buy),
            o.pairIndex,
            o.buy,
            o.positionSize.mul(o.leverage)
        );

        a.price = priceAfterImpact;
    } else {
        a.price = _marketExecutionPrice(a.price, a.spreadP, o.buy);
    }

    if (
      t == IExecute.OpenLimitOrderType.MARKET
        ? (a.price >= o.minPrice && a.price <= o.maxPrice)
        : (
          t == IExecute.OpenLimitOrderType.REVERSAL
            ? (o.buy ? a.price >= o.maxPrice : a.price <= o.minPrice)
            : (o.buy ? a.price <= o.maxPrice : a.price >= o.minPrice)
        ) && _withinExposureLimits(o.trader, o.pairIndex,
            o.positionSize.mul(o.leverage))
    ) {
        ITradingStorage.Trade memory finalTrade = _registerTrade(
            ITradingStorage.Trade(
                o.trader,
                o.pairIndex,
                0,
                0,
                o.positionSize,
                a.price, // current price
                o.buy,
                o.leverage,
                o.tp,
                o.sl, // large value 
                0
            )
        );

Here, the a.price is the current price of the token, o.sl is still larger than this price, and the trade would be registered. As the stop loss is greater than the current price of the token, the stop loss could be triggered successfully. When the stop loss is triggered, the profitP is calculated in the callback function executeLimitCloseOrderCallback:

function executeLimitCloseOrderCallback(AggregatorAnswer memory a)
external override onlyPriceAggregator {
    //...
    v.price = aggregator.pairsStorage().guaranteedSlEnabled(t.pairIndex)
      ? o.orderType == ITradingStorage.LimitOrder.TP ? t.tp :
        o.orderType == ITradingStorage.LimitOrder.SL
          ? t.sl
          : a.price
      : a.price;

    v.profitP = _currentPercentProfit(t.openPrice, v.price, t.buy,
        t.leverage);

    v.posToken = t.initialPosToken;
    v.posUSDC = t.initialPosToken;

    if (o.orderType == ITradingStorage.LimitOrder.LIQ) {
        uint liqPrice = pairInfos.getTradeLiquidationPrice(
            t.trader,
            t.pairIndex,
            t.index,
            t.openPrice,
            t.buy,
            v.posUSDC,
            t.leverage
        );
        v.reward = (t.buy ? a.price <= liqPrice : a.price >= liqPrice) ?
            (v.posToken * liqFeeP) / 100 : 0;
    } else {
        v.reward = (o.orderType == ITradingStorage.LimitOrder.TP &&
            t.tp > 0 &&
            (t.buy ? a.price >= t.tp : a.price <= t.tp)) ||
            (o.orderType == ITradingStorage.LimitOrder.SL &&
                t.sl > 0 &&
                (t.buy ? a.price <= t.sl : a.price >= t.sl))
            ? (
                v.posToken.mul(t.leverage) *
                aggregator.pairsStorage().pairLimitOrderFeeP(t.pairIndex)
            ) / 100 / _PRECISION
            : 0;
    }

    if (o.orderType == ITradingStorage.LimitOrder.LIQ && v.reward > 0) {
        uint usdcSentToTrader = _unregisterTrade(
            t,
            v.profitP,
            v.posUSDC,
            v.reward,
            (v.reward * (liqTotalFeeP - liqFeeP)) / liqFeeP,
            i.lossProtection
        );

When the profitP is calculated, it will set the v.price to the t.sl, which was higher than the current price of the token. Due to the vulnerability — as the t.sl could be set higher than the t.openPrice — it would always return in positive net profit when this function is called. The stop loss could be set to a really large value, leading to the maximum profit (900%) as allowed by the protocol and fund loss.

Here is an example trade:

Start
  Balance of trader before: 10000000000
  Trader places a limit order with large openPrice and stop loss just below it. Here is an example trade:
    trade.trader = _trader;
    trade.pairIndex = _pairIndex;
    trade.index = _index;
    trade.initialPosToken = 0;
    trade.positionSizeUSDC = _amount;
    trade.openPrice =  100000e10;
    trade.buy = true;
    trade.leverage = 10e10;
    trade.tp = 0;
    trade.sl = 100000e10-1;
    trade.timestamp = block.number;
  
  When the open order is executed, the openPrice is changed to the current price + price impact:
    OpenPrice for the long order 505252500000000
    Stop loss for the long order 999999999999999
 
  Stop loss is then immediately executed as the value of stop loss is greater than the current price.
  Balance of trader after: 97911000000

Impact

An attacker can create a malicious trade such that they always make 900% returns instantly. They could use this to drain the protocol.

Recommendations

After we discussed the issue with the Avantis team, they suggested removing the MOMENTUM order type, which prevents users from setting stop loss higher than the current price and prevents this issue.

Remediation

This issue has been acknowledged by Avantis Labs, Inc., and a fix was implemented in commit 2609b4c5↗.

Zellic Ā© 2025Back to top ↑