Skip to main content
This tutorial walks through building a custom programmatic order type — a smart contract that generates CoW Protocol orders based on on-chain conditions. By the end, you’ll have a working TradeAboveThreshold handler that automatically sells tokens when a wallet’s balance exceeds a threshold.

What You’ll Build

A TradeAboveThreshold programmatic order that:
  • Monitors a token balance in a Safe wallet
  • When the balance exceeds a threshold, generates a sell order for the excess
  • Automatically repeats until cancelled
Safe balance: 15,000 USDC (threshold: 10,000)
→ Generates order: sell 5,000 USDC for WETH
→ Watch Tower submits it to CoW Protocol
→ Order fills, balance drops to ~10,000 USDC
→ Next block: balance below threshold, no order generated

Prerequisites

  • Foundry installed
  • A Safe wallet with the ComposableCoW setup completed (fallback handler + domain verifier)
  • Basic Solidity knowledge

Step 1: Project Setup

mkdir custom-order && cd custom-order
forge init
forge install cowprotocol/composable-cow
Create remappings.txt:
@cowprotocol/=lib/composable-cow/
@openzeppelin/=lib/composable-cow/lib/openzeppelin-contracts/
safe/=lib/composable-cow/lib/safe-contracts/contracts/

Step 2: Implement the Handler

Create src/TradeAboveThreshold.sol:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;

import {BaseConditionalOrder} from "@cowprotocol/src/BaseConditionalOrder.sol";
import {GPv2Order} from "@cowprotocol/lib/cowprotocol/src/contracts/libraries/GPv2Order.sol";
import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";

/// @title TradeAboveThreshold
/// @notice Sells excess token balance above a threshold
contract TradeAboveThreshold is BaseConditionalOrder {
    /// @dev Order parameters, abi-encoded as `staticInput` when creating the order
    struct Data {
        IERC20 sellToken;
        IERC20 buyToken;
        uint256 threshold;     // Balance threshold in sell token units
        uint256 maxSellAmount; // Cap per trade to limit exposure
        bytes32 appData;
    }

    /// @inheritdoc IConditionalOrder
    function getTradeableOrder(
        address owner,
        address,
        bytes32,
        bytes calldata staticInput,
        bytes calldata
    ) public view override returns (GPv2Order.Data memory order) {
        Data memory data = abi.decode(staticInput, (Data));

        uint256 balance = data.sellToken.balanceOf(owner);

        // Revert if balance is below threshold — tells Watch Tower to retry later
        if (balance <= data.threshold) {
            revert IConditionalOrder.PollTryNextBlock("Balance below threshold");
        }

        uint256 excess = balance - data.threshold;
        uint256 sellAmount = excess > data.maxSellAmount
            ? data.maxSellAmount
            : excess;

        // Build the order
        order = GPv2Order.Data({
            sellToken: data.sellToken,
            buyToken: data.buyToken,
            receiver: owner,                          // Tokens back to the Safe
            sellAmount: sellAmount,
            buyAmount: 1,                              // Minimum 1 wei (use oracle for real price)
            validTo: uint32(block.timestamp + 1800),   // 30 min validity
            appData: data.appData,
            feeAmount: 0,
            kind: GPv2Order.KIND_SELL,
            partiallyFillable: false,
            sellTokenBalance: GPv2Order.BALANCE_ERC20,
            buyTokenBalance: GPv2Order.BALANCE_ERC20
        });
    }
}

Key Design Decisions

DecisionRationale
PollTryNextBlock revertTells the Watch Tower to check again next block instead of giving up
maxSellAmount capPrevents selling the entire balance in one trade
buyAmount: 1Placeholder — in production, use a price oracle for proper price protection
validTo: +30 minShort validity since conditions can change quickly

Step 3: Add Price Protection (Optional)

For production, add an oracle to set a reasonable buyAmount:
import {AggregatorV3Interface} from "chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

struct Data {
    IERC20 sellToken;
    IERC20 buyToken;
    uint256 threshold;
    uint256 maxSellAmount;
    bytes32 appData;
    AggregatorV3Interface priceFeed;  // Chainlink oracle
    uint256 maxSlippageBps;           // e.g., 100 = 1%
}

// In getTradeableOrder:
(, int256 price,,,) = data.priceFeed.latestRoundData();
uint256 expectedBuy = (sellAmount * uint256(price)) / (10 ** data.priceFeed.decimals());
uint256 minBuy = expectedBuy * (10000 - data.maxSlippageBps) / 10000;
order.buyAmount = minBuy;

Step 4: Write Tests

Create test/TradeAboveThreshold.t.sol:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "../src/TradeAboveThreshold.sol";
import {GPv2Order} from "@cowprotocol/lib/cowprotocol/src/contracts/libraries/GPv2Order.sol";
import {IConditionalOrder} from "@cowprotocol/src/interfaces/IConditionalOrder.sol";

contract MockERC20 {
    mapping(address => uint256) public balanceOf;
    function setBalance(address account, uint256 amount) external {
        balanceOf[account] = amount;
    }
}

contract TradeAboveThresholdTest is Test {
    TradeAboveThreshold handler;
    MockERC20 sellToken;
    MockERC20 buyToken;
    address owner = address(0x1234);

    function setUp() public {
        handler = new TradeAboveThreshold();
        sellToken = new MockERC20();
        buyToken = new MockERC20();
    }

    function testGeneratesOrderAboveThreshold() public {
        sellToken.setBalance(owner, 15000e6); // 15,000 USDC

        bytes memory staticInput = abi.encode(
            TradeAboveThreshold.Data({
                sellToken: IERC20(address(sellToken)),
                buyToken: IERC20(address(buyToken)),
                threshold: 10000e6,     // 10,000 USDC threshold
                maxSellAmount: 10000e6, // Max 10,000 per trade
                appData: bytes32(0)
            })
        );

        GPv2Order.Data memory order = handler.getTradeableOrder(
            owner, address(0), bytes32(0), staticInput, ""
        );

        assertEq(order.sellAmount, 5000e6); // Excess: 15,000 - 10,000
    }

    function testRevertsAtThreshold() public {
        sellToken.setBalance(owner, 10000e6); // Exactly at threshold

        bytes memory staticInput = abi.encode(
            TradeAboveThreshold.Data({
                sellToken: IERC20(address(sellToken)),
                buyToken: IERC20(address(buyToken)),
                threshold: 10000e6,
                maxSellAmount: 10000e6,
                appData: bytes32(0)
            })
        );

        vm.expectRevert();
        handler.getTradeableOrder(owner, address(0), bytes32(0), staticInput, "");
    }

    function testCapsAtMaxSellAmount() public {
        sellToken.setBalance(owner, 50000e6); // 50,000 USDC

        bytes memory staticInput = abi.encode(
            TradeAboveThreshold.Data({
                sellToken: IERC20(address(sellToken)),
                buyToken: IERC20(address(buyToken)),
                threshold: 10000e6,
                maxSellAmount: 5000e6,  // Cap at 5,000
                appData: bytes32(0)
            })
        );

        GPv2Order.Data memory order = handler.getTradeableOrder(
            owner, address(0), bytes32(0), staticInput, ""
        );

        assertEq(order.sellAmount, 5000e6); // Capped, not full 40,000
    }
}
Run tests:
forge test -vvv

Step 5: Deploy

# Deploy to Sepolia first
forge create src/TradeAboveThreshold.sol:TradeAboveThreshold \
  --rpc-url $SEPOLIA_RPC_URL \
  --private-key $DEPLOYER_KEY \
  --verify \
  --etherscan-api-key $ETHERSCAN_KEY
Note the deployed address — you’ll need it to create orders.

Step 6: Create an Order

Use the ComposableCoW contract to register your programmatic order:
import { encodeFunctionData, encodeAbiParameters } from 'viem'

const COMPOSABLE_COW = '0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74'
const HANDLER_ADDRESS = '0xYourDeployedHandlerAddress'

// Encode the handler's static input
const staticInput = encodeAbiParameters(
  [
    { type: 'address' }, // sellToken
    { type: 'address' }, // buyToken
    { type: 'uint256' }, // threshold
    { type: 'uint256' }, // maxSellAmount
    { type: 'bytes32' }, // appData
  ],
  [
    USDC_ADDRESS,
    WETH_ADDRESS,
    10000n * 10n ** 6n,   // 10,000 USDC threshold
    5000n * 10n ** 6n,    // Max 5,000 USDC per trade
    '0x0000000000000000000000000000000000000000000000000000000000000000',
  ],
)

// Create the programmatic order via ComposableCoW
const createCalldata = encodeFunctionData({
  abi: [{
    name: 'create',
    type: 'function',
    inputs: [
      {
        name: 'params',
        type: 'tuple',
        components: [
          { name: 'handler', type: 'address' },
          { name: 'salt', type: 'bytes32' },
          { name: 'staticInput', type: 'bytes' },
        ],
      },
      { name: 'dispatch', type: 'bool' },
    ],
    outputs: [],
  }],
  functionName: 'create',
  args: [
    {
      handler: HANDLER_ADDRESS,
      salt: '0x' + crypto.randomUUID().replace(/-/g, '') + '0'.repeat(32).slice(0, 32),
      staticInput,
    },
    true, // dispatch = true to emit event for Watch Tower
  ],
})

// Execute from the Safe
const safeTx = await safe.createTransaction({
  transactions: [{
    to: COMPOSABLE_COW,
    value: '0',
    data: createCalldata,
  }],
})

Step 7: Verify Watch Tower Picks It Up

After the creation transaction confirms, the Watch Tower should detect the ConditionalOrderCreated event and start polling your handler. Check the Watch Tower API:
# Check if your order appears in the Watch Tower's dump
curl https://barn.api.cow.fi/mainnet/api/v1/account/$SAFE_ADDRESS/orders
If the order doesn’t appear, see Debugging Programmatic Orders for troubleshooting.

Error Handling Best Practices

Use specific revert errors to guide the Watch Tower:
// Retry next block — temporary condition
revert IConditionalOrder.PollTryNextBlock("Balance below threshold");

// Retry at specific time — saves Watch Tower resources
revert IConditionalOrder.PollTryAtEpoch(block.timestamp + 3600, "Check back in 1 hour");

// Never retry — permanent condition
revert IConditionalOrder.PollNever("Order permanently invalid");

Architecture

Safe Wallet
  └── ComposableCoW (fallback handler)
        └── Your Handler (TradeAboveThreshold)
              └── getTradeableOrder() called by Watch Tower
                    └── Returns GPv2Order.Data if conditions met
                          └── Watch Tower submits to OrderBook API
                                └── Solvers compete to fill

Next Steps

Last modified on March 12, 2026