Skip to main content
Good After Time (GAT) orders allow you to schedule trades that only become valid after a specified time. They support optional price validation using Milkman price checkers and can be repeatedly filled by varying the buyAmount via off-chain input.

How It Works

A GAT order remains dormant until its startTime is reached. After that, it becomes valid until endTime, provided:
  1. The owner’s balance meets the minSellBalance requirement
  2. The off-chain provided buyAmount passes optional price checker validation
Since the buyAmount can vary between calls, each variation creates a unique orderUid, allowing multiple fills.

Data Structure

struct Data {
    IERC20 sellToken;
    IERC20 buyToken;
    address receiver;
    uint256 sellAmount;
    uint256 minSellBalance;
    uint256 startTime;
    uint256 endTime;
    bool allowPartialFill;
    bytes priceCheckerPayload;
    bytes32 appData;
}

Price Checker Payload

Optional nested structure for price validation:
struct PriceCheckerPayload {
    IExpectedOutCalculator checker;
    bytes payload;
    uint256 allowedSlippage;  // in basis points
}

Parameters

sellToken
IERC20
required
The token to sell
buyToken
IERC20
required
The token to receive in exchange
receiver
address
required
The address that will receive the bought tokens
sellAmount
uint256
required
The exact amount of sellToken to sell in the order
minSellBalance
uint256
required
Minimum balance of sellToken the owner must have for the order to be valid. Provides replay protection by ensuring sufficient funds.Example: If you want to sell 1000 tokens but only allow execution if you have at least 1000 tokens, set minSellBalance = 1000e18
startTime
uint256
required
UNIX timestamp when the order becomes valid. The order will not execute before this time.Example: block.timestamp + 1 days for orders valid starting tomorrow
endTime
uint256
required
UNIX timestamp when the order expires. Used as the validTo field in the GPv2 order.
allowPartialFill
bool
required
Whether solvers can partially fill the order
priceCheckerPayload
bytes
required
ABI-encoded PriceCheckerPayload struct for price validation, or empty bytes to skip price checking.When provided, the order validates that the off-chain supplied buyAmount meets minimum price requirements.
appData
bytes32
required
The IPFS hash of the appData associated with the order

Price Checker Parameters

checker
IExpectedOutCalculator
A Milkman-compatible price checker contract that calculates expected output amounts
payload
bytes
Arbitrary data passed to the price checker (checker-specific format)
allowedSlippage
uint256
Maximum allowed slippage in basis points (1 bp = 0.01%)Examples:
  • 100 = 1% slippage
  • 50 = 0.5% slippage
  • 500 = 5% slippage
The minimum acceptable buyAmount is calculated as:
minBuyAmount = expectedOut * (10000 - allowedSlippage) / 10000

Off-Chain Input

GAT orders require off-chain input to specify the buyAmount:
bytes memory offchainInput = abi.encode(buyAmount);
The off-chain watcher or user must provide the desired buyAmount when querying the order. Different buyAmount values create different orderUids, allowing multiple fills of the same order.

Behavior

Time-Based Activation

  1. Before startTime: Order reverts with PollTryAtEpoch(startTime) - watchers should poll again at that time
  2. Between startTime and endTime: Order is potentially valid (subject to balance and price checks)
  3. After endTime: Order expires and cannot be filled

Balance Check

The order validates that:
sellToken.balanceOf(owner) >= minSellBalance
This prevents execution after the order has been filled or funds have been moved.

Price Validation

If priceCheckerPayload is non-empty:
  1. Decode the PriceCheckerPayload
  2. Query the price checker for expected output: expectedOut = checker.getExpectedOut(sellAmount, sellToken, buyToken, payload)
  3. Calculate minimum acceptable amount: minAcceptable = expectedOut * (10000 - allowedSlippage) / 10000
  4. Validate: buyAmount >= minAcceptable
If validation fails, the order reverts with PollTryNextBlock(PRICE_CHECKER_FAILED).

Error Messages

TOO_EARLY
error
The current time is before startTime. The order will poll again at startTime.
BALANCE_INSUFFICIENT
error
The owner’s balance of sellToken is below minSellBalance. The order is invalid.
PRICE_CHECKER_FAILED
error
The provided buyAmount is below the minimum acceptable price. The order will poll again in the next block.

Example Usage

Example 1: Basic Scheduled Order

Sell 1000 USDC for DAI starting in 24 hours:
GoodAfterTime.Data memory order = GoodAfterTime.Data({
    sellToken: IERC20(USDC_ADDRESS),
    buyToken: IERC20(DAI_ADDRESS),
    receiver: msg.sender,
    sellAmount: 1000e6,                     // 1000 USDC
    minSellBalance: 1000e6,                 // Must have at least 1000 USDC
    startTime: block.timestamp + 1 days,    // Start in 24 hours
    endTime: block.timestamp + 2 days,      // Expire in 48 hours
    allowPartialFill: false,
    priceCheckerPayload: "",                // No price checker
    appData: bytes32(0)
});

bytes memory staticInput = abi.encode(order);

// Off-chain: provide buyAmount when querying
bytes memory offchainInput = abi.encode(990e18);  // Expect at least 990 DAI

Example 2: Order with Price Validation

Sell ETH for USDC with Milkman price checker:
// First, create the price checker payload
GoodAfterTime.PriceCheckerPayload memory priceChecker = GoodAfterTime.PriceCheckerPayload({
    checker: IExpectedOutCalculator(MILKMAN_CHECKER_ADDRESS),
    payload: abi.encode(UNISWAP_V3_POOL),   // Checker-specific data
    allowedSlippage: 100                     // 1% slippage
});

GoodAfterTime.Data memory order = GoodAfterTime.Data({
    sellToken: IERC20(WETH_ADDRESS),
    buyToken: IERC20(USDC_ADDRESS),
    receiver: msg.sender,
    sellAmount: 10e18,                      // 10 ETH
    minSellBalance: 10e18,
    startTime: block.timestamp + 1 hours,
    endTime: block.timestamp + 1 weeks,
    allowPartialFill: false,
    priceCheckerPayload: abi.encode(priceChecker),
    appData: bytes32(0)
});

bytes memory staticInput = abi.encode(order);

// Off-chain: provide buyAmount (will be validated against Milkman)
bytes memory offchainInput = abi.encode(22000e6);  // 22000 USDC

Example 3: Repeatable Order with Partial Fills

Sell DAI multiple times after token unlock:
GoodAfterTime.Data memory order = GoodAfterTime.Data({
    sellToken: IERC20(DAI_ADDRESS),
    buyToken: IERC20(USDC_ADDRESS),
    receiver: msg.sender,
    sellAmount: 500e18,                     // 500 DAI per fill
    minSellBalance: 500e18,                 // Can fill multiple times
    startTime: TOKEN_UNLOCK_TIMESTAMP,      // After unlock
    endTime: TOKEN_UNLOCK_TIMESTAMP + 30 days,
    allowPartialFill: true,                 // Allow partial fills
    priceCheckerPayload: "",
    appData: bytes32(0)
});

bytes memory staticInput = abi.encode(order);

// Can be filled multiple times with different buyAmounts:
// Fill 1: buyAmount = 499e6 (creates orderUid1)
// Fill 2: buyAmount = 498e6 (creates orderUid2)
// etc.

Implementation Details

Contract Address

The Good After Time conditional order type is deployed at /src/types/GoodAfterTime.sol.

Key Function

function getTradeableOrder(
    address owner,
    address,
    bytes32,
    bytes calldata staticInput,
    bytes calldata offchainInput
) public view override returns (GPv2Order.Data memory order) {
    Data memory data = abi.decode(staticInput, (Data));
    
    // Check if order has started
    if (!(block.timestamp >= data.startTime)) {
        revert IConditionalOrder.PollTryAtEpoch(data.startTime, TOO_EARLY);
    }
    
    // Check balance
    if (!(data.sellToken.balanceOf(owner) >= data.minSellBalance)) {
        revert IConditionalOrder.OrderNotValid(BALANCE_INSUFFICIENT);
    }
    
    // Decode off-chain buy amount
    uint256 buyAmount = abi.decode(offchainInput, (uint256));
    
    // Optional price validation
    if (data.priceCheckerPayload.length > 0) {
        PriceCheckerPayload memory p = abi.decode(data.priceCheckerPayload, (PriceCheckerPayload));
        uint256 expectedOut = p.checker.getExpectedOut(
            data.sellAmount,
            data.sellToken,
            data.buyToken,
            p.payload
        );
        
        if (!(buyAmount >= (expectedOut * (Utils.MAX_BPS - p.allowedSlippage)) / Utils.MAX_BPS)) {
            revert IConditionalOrder.PollTryNextBlock(PRICE_CHECKER_FAILED);
        }
    }
    
    // Return order
    order = GPv2Order.Data(
        data.sellToken,
        data.buyToken,
        data.receiver,
        data.sellAmount,
        buyAmount,
        data.endTime.toUint32(),
        data.appData,
        0,  // zero fee
        GPv2Order.KIND_SELL,
        data.allowPartialFill,
        GPv2Order.BALANCE_ERC20,
        GPv2Order.BALANCE_ERC20
    );
}
The buyAmount is provided via off-chain input, allowing the same order to be filled multiple times with different prices. The minSellBalance check prevents over-execution.

Build docs developers (and LLMs) love