Skip to main content
Stop Loss orders automatically trigger when the price of your sell token falls below a specified strike price. They use Chainlink-compatible price oracles to monitor market conditions and execute protective trades.

How It Works

A Stop Loss order monitors the price ratio between two tokens using oracle feeds. When the ratio falls to or below your strike price, the order becomes executable. This provides automated risk management without manual monitoring.
// Example: Sell GNO if price falls below $200
// Using GNO/ETH and ETH/USD oracles
strike = 200 * 10^18  // $200 with 18 decimals
The order calculates: (sellTokenPrice / buyTokenPrice) <= strike

Data Structure

struct Data {
    IERC20 sellToken;
    IERC20 buyToken;
    uint256 sellAmount;
    uint256 buyAmount;
    bytes32 appData;
    address receiver;
    bool isSellOrder;
    bool isPartiallyFillable;
    uint32 validTo;
    IAggregatorV3Interface sellTokenPriceOracle;
    IAggregatorV3Interface buyTokenPriceOracle;
    int256 strike;
    uint256 maxTimeSinceLastOracleUpdate;
}

Parameters

sellToken
IERC20
required
The token to sell when the stop loss is triggered
buyToken
IERC20
required
The token to receive in exchange
sellAmount
uint256
required
For sell orders: the exact amount of sellToken to sellFor buy orders: the maximum amount of sellToken willing to sell
buyAmount
uint256
required
For sell orders: the minimum amount of buyToken to receiveFor buy orders: the exact amount of buyToken to receive
appData
bytes32
required
The IPFS hash of the appData associated with the order
receiver
address
required
The address that will receive the proceeds of the trade
isSellOrder
bool
required
Whether this is a sell order (true) or buy order (false)
isPartiallyFillable
bool
required
Whether solvers can partially fill the order. Useful when the exact amount isn’t known at placement time.
validTo
uint32
required
The UNIX timestamp until which the order is valid. Provides replay protection - the order will only execute once before this time.
sellTokenPriceOracle
IAggregatorV3Interface
required
A Chainlink-compatible oracle returning the sell token price in a specific numeraire (e.g., USD or ETH)Important: Must use the same numeraire as buyTokenPriceOracle
buyTokenPriceOracle
IAggregatorV3Interface
required
A Chainlink-compatible oracle returning the buy token price in the same numeraire as sellTokenPriceOracleImportant: Must use the same numeraire as sellTokenPriceOracle
strike
int256
required
The exchange rate (denominated in sellToken/buyToken) that triggers the stop loss. Specified with 18 decimals.The order triggers when: (sellTokenPrice * 10^18) / buyTokenPrice <= strikeExample: For a strike of $200, set strike = 200 * 10^18 = 200000000000000000000
maxTimeSinceLastOracleUpdate
uint256
required
Maximum staleness allowed for oracle data in seconds. If either oracle hasn’t updated within this time, the order polls for the next block.Should exceed both oracles’ update intervals. Common values:
  • Ethereum mainnet: 3600 (1 hour)
  • Gnosis Chain: 600 (10 minutes)

Oracle Requirements

Both oracles must be denominated in the same quote currency (numeraire). For example:
  • For GNO/USD stop loss: use GNO/ETH and USD/ETH oracles (both in ETH)
  • For token/USDC stop loss: use TOKEN/USD and USDC/USD oracles (both in USD)

Price Validation

The contract performs several oracle validations:
  1. Positive Prices: Both oracle prices must be greater than 0
  2. Freshness Check: Both oracles must have updated within maxTimeSinceLastOracleUpdate seconds
  3. Decimal Normalization: Prices are scaled to 18 decimals for comparison

Behavior

Strike Price Check

The order calculates the current exchange rate and compares it to the strike:
// Normalize oracle prices to 18 decimals
basePrice = scalePrice(sellTokenPrice, oracleDecimals, 18);
quotePrice = scalePrice(buyTokenPrice, oracleDecimals, 18);

// Check if strike is reached
if (basePrice * SCALING_FACTOR / quotePrice <= strike) {
    // Stop loss triggered, order is valid
} else {
    // Strike not reached, poll next block
}
Where SCALING_FACTOR = 10^18

Replay Protection

The validTo parameter ensures the order only executes once. After execution or expiration, the order becomes permanently invalid.

Order Type

Stop loss orders:
  • Use zero fees (limit orders)
  • Support both sell and buy order kinds
  • Can be partially fillable
  • Use ERC20 balance for both sell and buy tokens

Error Messages

ORDER_EXPIRED
error
The current time exceeds validTo. The order has expired and will not execute.
ORACLE_INVALID_PRICE
error
One or both oracles returned a price less than or equal to 0. The order will not execute.
ORACLE_STALE_PRICE
error
One or both oracles have not updated within maxTimeSinceLastOracleUpdate seconds. The order will poll again in the next block.
STRIKE_NOT_REACHED
error
The current price is above the strike price. The order will poll again in the next block.

Example Usage

Example 1: Protect GNO Position

Sell 100 GNO if the price falls below $200:
StopLoss.Data memory order = StopLoss.Data({
    sellToken: IERC20(GNO_ADDRESS),
    buyToken: IERC20(USDC_ADDRESS),
    sellAmount: 100e18,                    // 100 GNO
    buyAmount: 19000e6,                    // At least $190 (5% slippage)
    appData: bytes32(0),
    receiver: msg.sender,
    isSellOrder: true,
    isPartiallyFillable: false,
    validTo: uint32(block.timestamp + 30 days),
    sellTokenPriceOracle: IAggregatorV3Interface(GNO_USD_ORACLE),
    buyTokenPriceOracle: IAggregatorV3Interface(USDC_USD_ORACLE),
    strike: 200e18,                        // $200 strike
    maxTimeSinceLastOracleUpdate: 3600     // 1 hour staleness
});

bytes memory staticInput = abi.encode(order);

Example 2: Stop Loss with Partial Fill

Sell up to 50 ETH if price falls below $2000, allowing partial fills:
StopLoss.Data memory order = StopLoss.Data({
    sellToken: IERC20(WETH_ADDRESS),
    buyToken: IERC20(DAI_ADDRESS),
    sellAmount: 50e18,                     // Up to 50 ETH
    buyAmount: 95000e18,                   // At least $1900 per ETH
    appData: bytes32(0),
    receiver: msg.sender,
    isSellOrder: true,
    isPartiallyFillable: true,             // Allow partial fills
    validTo: uint32(block.timestamp + 7 days),
    sellTokenPriceOracle: IAggregatorV3Interface(ETH_USD_ORACLE),
    buyTokenPriceOracle: IAggregatorV3Interface(DAI_USD_ORACLE),
    strike: 2000e18,                       // $2000 strike
    maxTimeSinceLastOracleUpdate: 3600
});

bytes memory staticInput = abi.encode(order);

Example 3: Cross-Asset Stop Loss

Sell WBTC for ETH if BTC/ETH ratio falls below 15:
StopLoss.Data memory order = StopLoss.Data({
    sellToken: IERC20(WBTC_ADDRESS),
    buyToken: IERC20(WETH_ADDRESS),
    sellAmount: 5e8,                       // 5 BTC (8 decimals)
    buyAmount: 70e18,                      // At least 70 ETH (14:1 ratio with slippage)
    appData: bytes32(0),
    receiver: msg.sender,
    isSellOrder: true,
    isPartiallyFillable: false,
    validTo: uint32(block.timestamp + 14 days),
    sellTokenPriceOracle: IAggregatorV3Interface(BTC_USD_ORACLE),
    buyTokenPriceOracle: IAggregatorV3Interface(ETH_USD_ORACLE),
    strike: 15e18,                         // 15 ETH per BTC
    maxTimeSinceLastOracleUpdate: 3600
});

bytes memory staticInput = abi.encode(order);

Implementation Details

Contract Address

The Stop Loss conditional order type is deployed at /src/types/StopLoss.sol.

Key Function

function getTradeableOrder(
    address,
    address,
    bytes32,
    bytes calldata staticInput,
    bytes calldata
) public view override returns (GPv2Order.Data memory order) {
    Data memory data = abi.decode(staticInput, (Data));
    
    // Check expiration
    if (data.validTo < block.timestamp) {
        revert IConditionalOrder.OrderNotValid(ORDER_EXPIRED);
    }
    
    // Get oracle prices
    (, int256 basePrice,, uint256 sellUpdatedAt,) = data.sellTokenPriceOracle.latestRoundData();
    (, int256 quotePrice,, uint256 buyUpdatedAt,) = data.buyTokenPriceOracle.latestRoundData();
    
    // Validate prices
    if (!(basePrice > 0 && quotePrice > 0)) {
        revert IConditionalOrder.OrderNotValid(ORACLE_INVALID_PRICE);
    }
    
    // Check staleness
    if (!(
        sellUpdatedAt >= block.timestamp - data.maxTimeSinceLastOracleUpdate
        && buyUpdatedAt >= block.timestamp - data.maxTimeSinceLastOracleUpdate
    )) {
        revert IConditionalOrder.PollTryNextBlock(ORACLE_STALE_PRICE);
    }
    
    // Normalize decimals
    basePrice = Utils.scalePrice(basePrice, data.sellTokenPriceOracle.decimals(), 18);
    quotePrice = Utils.scalePrice(quotePrice, data.buyTokenPriceOracle.decimals(), 18);
    
    // Check strike
    if (!(basePrice * SCALING_FACTOR / quotePrice <= data.strike)) {
        revert IConditionalOrder.PollTryNextBlock(STRIKE_NOT_REACHED);
    }
    
    // Return order
    order = GPv2Order.Data(
        data.sellToken,
        data.buyToken,
        data.receiver,
        data.sellAmount,
        data.buyAmount,
        data.validTo,
        data.appData,
        0,  // zero fee for limit orders
        data.isSellOrder ? GPv2Order.KIND_SELL : GPv2Order.KIND_BUY,
        data.isPartiallyFillable,
        GPv2Order.BALANCE_ERC20,
        GPv2Order.BALANCE_ERC20
    );
}
Stop loss orders provide one-time protection. After execution, you’ll need to create a new order for continued protection.

Build docs developers (and LLMs) love