Skip to main content
Perpetual Stable Swap orders automatically maintain balance between two tokens (typically stablecoins) by continuously trading whichever token you have more of. The order adds a configurable spread to profit from the rebalancing.

How It Works

The contract checks your balance of both tokens and automatically creates a sell order for whichever token you have more of. The trade aims to bring both balances closer to equal, while adding a half-spread markup to the exchange rate.
// If you hold more tokenA than tokenB:
// Sell tokenA for tokenB at: 1 + halfSpreadBps

// If you hold more tokenB than tokenA:
// Sell tokenB for tokenA at: 1 + halfSpreadBps
The order is perpetual - it continuously rebalances as your holdings change.

Data Structure

struct Data {
    IERC20 tokenA;
    IERC20 tokenB;
    uint32 validityBucketSeconds;
    uint256 halfSpreadBps;
    bytes32 appData;
}

Parameters

tokenA
IERC20
required
One of the two tokens to swap between. Typically a stablecoin (e.g., USDC).
tokenB
IERC20
required
The other token to swap between. Typically another stablecoin (e.g., DAI).
validityBucketSeconds
uint32
required
The width of the validity bucket in seconds. This determines how long each order is valid and helps prevent order collision.The validTo timestamp is calculated as:
validTo = ((currentTime / validityBucketSeconds) * validityBucketSeconds) + validityBucketSeconds
This buckets time into fixed windows, ensuring orders queried within the same window have the same validTo (and thus same orderUid).Recommended values:
  • 604800 (1 week)
  • 1209600 (2 weeks)
Validity should be between 1-2 weeks. Shorter periods can cause order spam, longer periods may delay updates.
halfSpreadBps
uint256
required
The markup above parity (1:1 exchange rate) charged for each swap, in basis points (1 bp = 0.01%).The buy amount is calculated as:
buyAmount = convertAmount(sellAmount) * (10000 + halfSpreadBps) / 10000
Examples:
  • 0 = No spread, trade at parity
  • 10 = 0.1% markup
  • 50 = 0.5% markup
  • 100 = 1% markup
With zero spread, orders may not generate surplus. This can cause order collision issues unless balances are continuously changing.
appData
bytes32
required
The IPFS hash of the appData associated with the order

Behavior

Side Selection

The contract automatically determines which token to sell:
  1. Get balances: balanceA = tokenA.balanceOf(owner) and balanceB = tokenB.balanceOf(owner)
  2. Convert balances to common decimals: normalizedA = convertAmount(tokenA, balanceA, tokenB)
  3. Compare: if normalizedA > balanceB, sell tokenA; otherwise sell tokenB

Amount Conversion

The contract handles different token decimals automatically:
function convertAmount(IERC20 srcToken, uint256 srcAmount, IERC20 destToken)
    internal view returns (uint256 destAmount)
{
    uint8 srcDecimals = srcToken.decimals();
    uint8 destDecimals = destToken.decimals();
    
    if (srcDecimals > destDecimals) {
        destAmount = srcAmount / (10 ** (srcDecimals - destDecimals));
    } else {
        destAmount = srcAmount * (10 ** (destDecimals - srcDecimals));
    }
}
Example: Converting 1000 USDC (6 decimals) to DAI (18 decimals):
1000 * 10^6 * 10^(18-6) = 1000 * 10^18

Receiver

The order always sets receiver = address(0), which is a special value in CoW Protocol that means “send tokens to the order owner” (self).

Order Properties

  • Kind: Always sell orders (KIND_SELL)
  • Partially fillable: No (false)
  • Fee: 0 (limit order)
  • Balance: ERC20 balances for both tokens

Validation

NOT_FUNDED
error
The sell amount is 0, meaning the owner has no balance of the token that should be sold. The order is invalid.

Example Usage

Example 1: USDC/DAI Rebalancing

Automatically maintain equal USDC and DAI holdings with 0.1% spread:
PerpetualStableSwap.Data memory order = PerpetualStableSwap.Data({
    tokenA: IERC20(USDC_ADDRESS),
    tokenB: IERC20(DAI_ADDRESS),
    validityBucketSeconds: 604800,    // 1 week
    halfSpreadBps: 10,                // 0.1% markup
    appData: bytes32(0)
});

bytes memory staticInput = abi.encode(order);
Scenario:
  • You hold 1000 USDC and 500 DAI
  • Contract sells 1000 USDC for at least 1001 DAI (1000 + 0.1%)
  • After trade, you have ~0 USDC and ~1501 DAI
  • Next time, contract sells DAI back to USDC
  • Continues rebalancing perpetually

Example 2: Multi-Stablecoin with Higher Spread

Rebalance between USDT and USDC with 0.5% spread:
PerpetualStableSwap.Data memory order = PerpetualStableSwap.Data({
    tokenA: IERC20(USDT_ADDRESS),
    tokenB: IERC20(USDC_ADDRESS),
    validityBucketSeconds: 1209600,   // 2 weeks
    halfSpreadBps: 50,                // 0.5% markup
    appData: bytes32(0)
});

bytes memory staticInput = abi.encode(order);

Example 3: Zero Spread Rebalancing

Rebalance at parity (no profit, pure rebalancing):
PerpetualStableSwap.Data memory order = PerpetualStableSwap.Data({
    tokenA: IERC20(DAI_ADDRESS),
    tokenB: IERC20(FRAX_ADDRESS),
    validityBucketSeconds: 604800,
    halfSpreadBps: 0,                 // No spread
    appData: bytes32(0)
});

bytes memory staticInput = abi.encode(order);
With zero spread, ensure your balances change frequently to avoid order collision issues.

Use Cases

Liquidity Provision

Maintain balanced holdings while providing liquidity:
  • Hold equal amounts of two stablecoins
  • Automatically rebalance as one is depleted
  • Earn spread on each rebalancing trade

Yield Optimization

Capture small price deviations between stablecoins:
  • Set small spread (0.1-0.5%)
  • Profit from peg deviations
  • Automatically rebalance to starting position

Portfolio Management

Maintain exposure to multiple stablecoins:
  • Split holdings across different stablecoins for diversification
  • Automatically rebalance to maintain equal positions
  • Reduce risk from single stablecoin depegs

Implementation Details

Contract Address

The Perpetual Stable Swap conditional order type is deployed at /src/types/PerpetualStableSwap.sol.

Key Functions

function getTradeableOrder(
    address owner,
    address,
    bytes32,
    bytes calldata staticInput,
    bytes calldata
) public view override returns (GPv2Order.Data memory order) {
    PerpetualStableSwap.Data memory data = abi.decode(staticInput, (Data));
    
    // Determine which token to sell
    BuySellData memory buySellData = side(owner, data);
    
    // Ensure order is funded
    if (!(buySellData.sellAmount > 0)) {
        revert IConditionalOrder.OrderNotValid(NOT_FUNDED);
    }
    
    // Create order
    order = GPv2Order.Data(
        buySellData.sellToken,
        buySellData.buyToken,
        address(0),  // receiver = self
        buySellData.sellAmount,
        buySellData.buyAmount,
        Utils.validToBucket(data.validityBucketSeconds),
        data.appData,
        0,
        GPv2Order.KIND_SELL,
        false,
        GPv2Order.BALANCE_ERC20,
        GPv2Order.BALANCE_ERC20
    );
}
The order uses validity buckets to ensure consistent orderUid values when queried multiple times within the same time window. This prevents order spam on the CoW Protocol orderbook.

Build docs developers (and LLMs) love