From e44be5625870321297e6476a25fc5c25de0912ad Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 14:47:34 +0000 Subject: [PATCH 01/25] chore: upgrade to Solidity 0.8.30 Enable require(condition, CustomError()) syntax for cleaner error handling. --- foundry.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundry.toml b/foundry.toml index 8b8cffa..66de8d2 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,7 +3,7 @@ src = 'src' out = 'out' libs = ['lib'] -solc_version = "0.8.19" +solc_version = "0.8.30" optimizer = true optimizer_runs = 20000 From 7e16375bb40a1a6589fbc7ff93dce420238f12b2 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 14:48:15 +0000 Subject: [PATCH 02/25] feat!: redesign IConditionalOrder interface with dual-path support BREAKING CHANGE: Replace getTradeableOrder with generateOrder/poll split - Rename getTradeableOrder to generateOrder as single source of truth - Add generateOrder to IConditionalOrder base interface - Rename PollTryAtEpoch to PollTryAtTimestamp for clarity - Remove PollNever error (use OrderNotValid instead) - Add IConditionalOrderGenerator with poll(), getNextPollTimestamp(), describeOrder() - Add PollResultCode enum: SUCCESS, PARTIALLY_FILLED, FILLED, WAIT_TIMESTAMP, WAIT_BLOCK, TRY_NEXT_BLOCK, INVALID - Add PollResult struct with order, scheduling hints, and fill amount - Clean up documentation comments --- src/interfaces/IConditionalOrder.sol | 146 +++++++++++++++------------ 1 file changed, 83 insertions(+), 63 deletions(-) diff --git a/src/interfaces/IConditionalOrder.sol b/src/interfaces/IConditionalOrder.sol index c3b789c..fecd1b0 100644 --- a/src/interfaces/IConditionalOrder.sol +++ b/src/interfaces/IConditionalOrder.sol @@ -2,55 +2,52 @@ pragma solidity >=0.8.0 <0.9.0; import {GPv2Order} from "cowprotocol/contracts/libraries/GPv2Order.sol"; -import {GPv2Interaction} from "cowprotocol/contracts/libraries/GPv2Interaction.sol"; import {IERC165} from "safe/interfaces/IERC165.sol"; -/** - * @title Conditional Order Interface - * @author CoW Protocol Developers + mfw78 - */ +/// @title IConditionalOrder - Base interface for conditional orders +/// @author CoW Protocol Developers + mfw78 +/// @notice Defines core order generation and settlement verification interface IConditionalOrder { - - /// @dev This error is returned by the `getTradeableOrder` function if the order condition is not met. - /// A parameter of `string` type is included to allow the caller to specify the reason for the failure. - error OrderNotValid(string); + /// @notice Order condition permanently not met + error OrderNotValid(string reason); - // --- errors specific for polling - // Signal to a watch tower that polling should be attempted again. + /// @notice Condition not met, retry next block error PollTryNextBlock(string reason); - // Signal to a watch tower that polling should be attempted again at a specific block number. + + /// @notice Condition not met, retry at timestamp + error PollTryAtTimestamp(uint256 timestamp, string reason); + + /// @notice Condition not met, retry at block error PollTryAtBlock(uint256 blockNumber, string reason); - // Signal to a watch tower that polling should be attempted again at a specific epoch (unix timestamp). - error PollTryAtEpoch(uint256 timestamp, string reason); - // Signal to a watch tower that the conditional order should not be polled again (delete). - error PollNever(string reason); - /** - * @dev This struct is used to uniquely identify a conditional order for an owner. - * H(handler || salt || staticInput) **MUST** be unique for an owner. - */ + /// @notice Parameters uniquely identifying a conditional order + /// @dev H(handler || salt || staticInput) must be unique per owner struct ConditionalOrderParams { IConditionalOrder handler; bytes32 salt; bytes staticInput; } - /** - * Verify if a given discrete order is valid. - * @dev Used in combination with `isValidSafeSignature` to verify that the order is signed by the Safe. - * **MUST** revert if the order condition is not met. - * @dev The `order` parameter is ignored / not validated by the `IConditionalOrderGenerator` implementation. - * This parameter is included to allow more granular control over the order verification logic, and to - * allow a watch tower / user to propose a discrete order without it being generated by on-chain logic. - * @param owner the contract who is the owner of the order - * @param sender the `msg.sender` of the transaction - * @param _hash the hash of the order - * @param domainSeparator the domain separator used to sign the order - * @param ctx the context key of the order (bytes32(0) if a merkle tree is used, otherwise H(params)) with which to lookup the cabinet - * @param staticInput the static input for all discrete orders cut from this conditional order - * @param offchainInput dynamic off-chain input for a discrete order cut from this conditional order - * @param order `GPv2Order.Data` of a discrete order to be verified (if *not* an `IConditionalOrderGenerator`). - */ + /// @notice Generate the discrete order for current conditions + /// @dev Single source of truth for order logic. Used by both verify() and poll(). + /// @dev MUST revert with appropriate error if conditions not met. + /// @param owner The Safe/wallet that owns this order + /// @param sender The address initiating the call + /// @param ctx Context key (bytes32(0) for merkle, hash(params) for single) + /// @param staticInput Fixed order parameters (abi-encoded) + /// @param offchainInput Dynamic parameters from watch-tower + /// @return order The discrete order for CoW Protocol + function generateOrder( + address owner, + address sender, + bytes32 ctx, + bytes calldata staticInput, + bytes calldata offchainInput + ) external view returns (GPv2Order.Data memory order); + + /// @notice Verify an order for settlement + /// @dev Called via ComposableCoW during EIP-1271 signature verification. + /// @dev MUST revert if order should not settle. function verify( address owner, address sender, @@ -63,33 +60,56 @@ interface IConditionalOrder { ) external view; } -/** - * @title Conditional Order Generator Interface - * @author mfw78 - */ +/// @title IConditionalOrderGenerator - Extended interface with polling support +/// @author mfw78 +/// @notice Adds structured polling results for watch-tower integration interface IConditionalOrderGenerator is IConditionalOrder, IERC165 { - /** - * @dev This event is emitted when a new conditional order is created. - * @param owner the address that has created the conditional order - * @param params the address / salt / data of the conditional order - */ - event ConditionalOrderCreated(address indexed owner, IConditionalOrder.ConditionalOrderParams params); + /// @notice Emitted when a conditional order is created with dispatch=true + event ConditionalOrderCreated(address indexed owner, ConditionalOrderParams params); - /** - * @dev Get a tradeable order that can be posted to the CoW Protocol API and would pass signature validation. - * **MUST** revert if the order condition is not met. - * @param owner the contract who is the owner of the order - * @param sender the `msg.sender` of the parent `isValidSignature` call - * @param ctx the context of the order (bytes32(0) if merkle tree is used, otherwise the H(params)) - * @param staticInput the static input for all discrete orders cut from this conditional order - * @param offchainInput dynamic off-chain input for a discrete order cut from this conditional order - * @return the tradeable order for submission to the CoW Protocol API - */ - function getTradeableOrder( - address owner, - address sender, - bytes32 ctx, - bytes calldata staticInput, - bytes calldata offchainInput - ) external view returns (GPv2Order.Data memory); + /// @notice Result codes for poll() calls + enum PollResultCode { + SUCCESS, // Order ready to trade + PARTIALLY_FILLED, // Order partially filled, no action needed (informational) + FILLED, // Order completely filled, no action needed + WAIT_TIMESTAMP, // Wait for specific timestamp + WAIT_BLOCK, // Wait for specific block + TRY_NEXT_BLOCK, // Transient condition, retry next block + INVALID // Permanently invalid, stop polling + } + + /// @notice Structured result from poll() + /// @dev Field validity depends on code - see docs/architecture.md + struct PollResult { + PollResultCode code; + GPv2Order.Data order; // Valid when code == SUCCESS, PARTIALLY_FILLED, or FILLED + uint256 nextPollTimestamp; // SUCCESS: when to poll for next order (0=validTo+1, max=never) + uint256 waitUntil; // WAIT_*: timestamp or block to wait for + string reason; // Human-readable status + uint256 filledAmount; // PARTIALLY_FILLED/FILLED: amount that was filled + } + + /// @notice Poll for a tradeable order with scheduling metadata + /// @dev Called by watch-towers. Never reverts for order conditions. + /// @dev Wraps generateOrder() with try/catch and adds polling hints. + /// @return result Structured result with order (if ready) and scheduling hints + function poll(address owner, address sender, bytes32 ctx, bytes calldata staticInput, bytes calldata offchainInput) + external + view + returns (PollResult memory result); + + /// @notice Get scheduling hint for next poll after successful order + /// @dev Only called by poll() after generateOrder() succeeds. + /// @return nextPollTimestamp 0=use validTo+1, max=stop polling, other=poll at time + function getNextPollTimestamp(address owner, bytes32 ctx, bytes calldata staticInput, GPv2Order.Data memory order) + external + view + returns (uint256 nextPollTimestamp); + + /// @notice Get human-readable order description + /// @dev Only for off-chain UX, not called during settlement. + function describeOrder(address owner, bytes32 ctx, bytes calldata staticInput, GPv2Order.Data memory order) + external + view + returns (string memory description); } From eb764b1ac3784f36bcfe35bd565f254ba6726092 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 14:51:15 +0000 Subject: [PATCH 03/25] feat: implement BaseConditionalOrder with dual-path support - Add verify() that calls generateOrder() and validates hash - Add poll() that wraps generateOrder() with try/catch for structured results - Add error decoding for OrderNotValid, PollTryNextBlock, PollTryAtTimestamp, PollTryAtBlock - Add default getNextPollTimestamp() returning POLL_AT_VALIDTO (0) - Add default describeOrder() returning generic message - Define POLL_AT_VALIDTO and POLL_NEVER constants for scheduling hints - Use string constant INVALID_HASH for error messages --- src/BaseConditionalOrder.sol | 205 ++++++++++++++++++++++++++++++----- 1 file changed, 179 insertions(+), 26 deletions(-) diff --git a/src/BaseConditionalOrder.sol b/src/BaseConditionalOrder.sol index 81a10ab..5083c97 100644 --- a/src/BaseConditionalOrder.sol +++ b/src/BaseConditionalOrder.sol @@ -5,22 +5,18 @@ import {GPv2Order, IERC20} from "cowprotocol/contracts/libraries/GPv2Order.sol"; import {IERC165, IConditionalOrder, IConditionalOrderGenerator} from "./interfaces/IConditionalOrder.sol"; -// --- error strings -/// @dev This error is returned by the `verify` function if the *generated* order hash does not match -/// the hash passed as a parameter. string constant INVALID_HASH = "invalid hash"; -/** - * @title Base logic for conditional orders. - * @dev Enforces the order verification logic for conditional orders, allowing developers - * to focus on the logic for generating the tradeable order. - * @author mfw78 - */ +/// @title BaseConditionalOrder - Base implementation for conditional orders +/// @author mfw78 +/// @notice Provides dual-path support: lean verify() for settlement, rich poll() for watch-towers abstract contract BaseConditionalOrder is IConditionalOrderGenerator { - /** - * @inheritdoc IConditionalOrder - * @dev As an order generator, the `GPv2Order.Data` passed as a parameter is ignored / not validated. - */ + /// @dev Signals poll() to use order.validTo + 1 as next poll time + uint256 internal constant POLL_AT_VALIDTO = 0; + /// @dev Signals poll() that this is the final order, stop polling after fill + uint256 internal constant POLL_NEVER = type(uint256).max; + + /// @inheritdoc IConditionalOrder function verify( address owner, address sender, @@ -31,30 +27,187 @@ abstract contract BaseConditionalOrder is IConditionalOrderGenerator { bytes calldata offchainInput, GPv2Order.Data calldata ) external view override { - GPv2Order.Data memory generatedOrder = getTradeableOrder(owner, sender, ctx, staticInput, offchainInput); + GPv2Order.Data memory order = generateOrder(owner, sender, ctx, staticInput, offchainInput); + require(_hash == GPv2Order.hash(order, domainSeparator), IConditionalOrder.OrderNotValid(INVALID_HASH)); + } - /// @dev Verify that the *generated* order is valid and matches the payload. - if (!(_hash == GPv2Order.hash(generatedOrder, domainSeparator))) { - revert IConditionalOrder.OrderNotValid(INVALID_HASH); + /// @inheritdoc IConditionalOrderGenerator + function poll(address owner, address sender, bytes32 ctx, bytes calldata staticInput, bytes calldata offchainInput) + external + view + override + returns (IConditionalOrderGenerator.PollResult memory result) + { + try this.generateOrder(owner, sender, ctx, staticInput, offchainInput) returns (GPv2Order.Data memory order) { + uint256 nextPoll = this.getNextPollTimestamp(owner, ctx, staticInput, order); + string memory description = this.describeOrder(owner, ctx, staticInput, order); + return IConditionalOrderGenerator.PollResult({ + code: IConditionalOrderGenerator.PollResultCode.SUCCESS, + order: order, + nextPollTimestamp: nextPoll, + waitUntil: 0, + reason: description, + filledAmount: 0 + }); + } catch (bytes memory errorData) { + return _decodeErrorToPollResult(errorData); } } - /** - * @dev Set the visibility of this function to `public` to allow `verify` to call it. - * @inheritdoc IConditionalOrderGenerator - */ - function getTradeableOrder( + /// @inheritdoc IConditionalOrderGenerator + /// @dev Default: use order.validTo + 1. Override for multi-part orders. + function getNextPollTimestamp(address, bytes32, bytes calldata, GPv2Order.Data memory) + external + view + virtual + override + returns (uint256) + { + return POLL_AT_VALIDTO; + } + + /// @inheritdoc IConditionalOrderGenerator + /// @dev Default: generic message. Override for better UX. + function describeOrder(address, bytes32, bytes calldata, GPv2Order.Data memory) + external + view + virtual + override + returns (string memory) + { + return "order ready"; + } + + /// @inheritdoc IConditionalOrder + function generateOrder( address owner, address sender, bytes32 ctx, bytes calldata staticInput, bytes calldata offchainInput - ) public view virtual override returns (GPv2Order.Data memory); + ) public view virtual override returns (GPv2Order.Data memory order); - /** - * @inheritdoc IERC165 - */ + /// @inheritdoc IERC165 function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) { return interfaceId == type(IConditionalOrderGenerator).interfaceId || interfaceId == type(IERC165).interfaceId; } + + /// @dev Decode revert data into a PollResult + function _decodeErrorToPollResult(bytes memory errorData) + internal + pure + returns (IConditionalOrderGenerator.PollResult memory) + { + if (errorData.length >= 4) { + bytes4 selector; + assembly { + selector := mload(add(errorData, 32)) + } + + // OrderNotValid(string) + if (selector == IConditionalOrder.OrderNotValid.selector) { + string memory reason = _decodeStringError(errorData); + return IConditionalOrderGenerator.PollResult({ + code: IConditionalOrderGenerator.PollResultCode.INVALID, + order: _emptyOrder(), + nextPollTimestamp: 0, + waitUntil: 0, + reason: reason, + filledAmount: 0 + }); + } + + // PollTryNextBlock(string) + if (selector == IConditionalOrder.PollTryNextBlock.selector) { + string memory reason = _decodeStringError(errorData); + return IConditionalOrderGenerator.PollResult({ + code: IConditionalOrderGenerator.PollResultCode.TRY_NEXT_BLOCK, + order: _emptyOrder(), + nextPollTimestamp: 0, + waitUntil: 0, + reason: reason, + filledAmount: 0 + }); + } + + // PollTryAtTimestamp(uint256, string) + if (selector == IConditionalOrder.PollTryAtTimestamp.selector) { + (uint256 timestamp, string memory reason) = _decodeTimestampError(errorData); + return IConditionalOrderGenerator.PollResult({ + code: IConditionalOrderGenerator.PollResultCode.WAIT_TIMESTAMP, + order: _emptyOrder(), + nextPollTimestamp: 0, + waitUntil: timestamp, + reason: reason, + filledAmount: 0 + }); + } + + // PollTryAtBlock(uint256, string) + if (selector == IConditionalOrder.PollTryAtBlock.selector) { + (uint256 blockNum, string memory reason) = _decodeTimestampError(errorData); + return IConditionalOrderGenerator.PollResult({ + code: IConditionalOrderGenerator.PollResultCode.WAIT_BLOCK, + order: _emptyOrder(), + nextPollTimestamp: 0, + waitUntil: blockNum, + reason: reason, + filledAmount: 0 + }); + } + } + + // Unknown error + return IConditionalOrderGenerator.PollResult({ + code: IConditionalOrderGenerator.PollResultCode.INVALID, + order: _emptyOrder(), + nextPollTimestamp: 0, + waitUntil: 0, + reason: "unknown error", + filledAmount: 0 + }); + } + + /// @dev Decode error with signature (string) + function _decodeStringError(bytes memory errorData) internal pure returns (string memory reason) { + if (errorData.length > 68) { + assembly { + errorData := add(errorData, 4) + } + reason = abi.decode(errorData, (string)); + } else { + reason = ""; + } + } + + /// @dev Decode error with signature (uint256, string) + function _decodeTimestampError(bytes memory errorData) internal pure returns (uint256 value, string memory reason) { + if (errorData.length > 68) { + assembly { + errorData := add(errorData, 4) + } + (value, reason) = abi.decode(errorData, (uint256, string)); + } else { + value = 0; + reason = ""; + } + } + + /// @dev Create empty order for non-SUCCESS results + function _emptyOrder() internal pure returns (GPv2Order.Data memory) { + return GPv2Order.Data({ + sellToken: IERC20(address(0)), + buyToken: IERC20(address(0)), + receiver: address(0), + sellAmount: 0, + buyAmount: 0, + validTo: 0, + appData: bytes32(0), + feeAmount: 0, + kind: bytes32(0), + partiallyFillable: false, + sellTokenBalance: bytes32(0), + buyTokenBalance: bytes32(0) + }); + } } From 2fdefe488fb54184e1d8860eda5da04e9991c1ee Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 17:42:34 +0000 Subject: [PATCH 04/25] feat: extend CoWSettlement interface with filledAmount Add filledAmount(bytes calldata orderUid) for querying order fill status. --- src/vendored/CoWSettlement.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vendored/CoWSettlement.sol b/src/vendored/CoWSettlement.sol index 3331cee..a3e4ee2 100644 --- a/src/vendored/CoWSettlement.sol +++ b/src/vendored/CoWSettlement.sol @@ -3,4 +3,5 @@ pragma solidity >=0.8.0 <0.9.0; interface CoWSettlement { function domainSeparator() external view returns (bytes32); + function filledAmount(bytes calldata orderUid) external view returns (uint256); } From bef906bd322d876460dddf1d40ce6c9749a5f671 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 17:43:01 +0000 Subject: [PATCH 05/25] refactor: update TWAP order type to new architecture - Extend BaseConditionalOrder instead of IConditionalOrderGenerator - Rename getTradeableOrder to generateOrder - Use require(condition, CustomError()) syntax - Use string constants for error messages - Override getNextPollTimestamp() for multi-part scheduling - Override describeOrder() for human-readable status - Clean up documentation comments --- src/types/twap/TWAP.sol | 93 ++++++++++++------- src/types/twap/libraries/TWAPOrder.sol | 54 ++++------- src/types/twap/libraries/TWAPOrderMathLib.sol | 53 ++++++----- 3 files changed, 107 insertions(+), 93 deletions(-) diff --git a/src/types/twap/TWAP.sol b/src/types/twap/TWAP.sol index ad4f83b..ef67968 100644 --- a/src/types/twap/TWAP.sol +++ b/src/types/twap/TWAP.sol @@ -2,7 +2,6 @@ pragma solidity >=0.8.0 <0.9.0; import {ComposableCoW} from "../../ComposableCoW.sol"; - import { IConditionalOrder, IConditionalOrderGenerator, @@ -10,20 +9,13 @@ import { BaseConditionalOrder } from "../../BaseConditionalOrder.sol"; import {TWAPOrder} from "./libraries/TWAPOrder.sol"; +import {TWAPOrderMathLib, AFTER_TWAP_FINISH} from "./libraries/TWAPOrderMathLib.sol"; -// --- error strings - -/// @dev The order is not within the TWAP bundle's span. -string constant NOT_WITHIN_SPAN = "not within span"; +string constant NOT_WITHIN_SPAN = "outside span"; -/** - * @title TWAP Conditional Order - * @author mfw78 - * @notice TWAP conditional orders allow for splitting an order into a series of orders that are - * executed at a fixed interval. This is useful for ensuring that a trade is executed at a - * specific price, even if the price of the token changes during the trade. - * @dev Designed to be used with the CoW Protocol Conditional Order Framework. - */ +/// @title TWAP Conditional Order +/// @author mfw78 +/// @notice Splits an order into multiple parts executed at fixed intervals. contract TWAP is BaseConditionalOrder { ComposableCoW public immutable composableCow; @@ -31,37 +23,76 @@ contract TWAP is BaseConditionalOrder { composableCow = _composableCow; } - /** - * @inheritdoc IConditionalOrderGenerator - * @dev `owner`, `sender` and `offchainInput` is not used. - */ - function getTradeableOrder(address owner, address, bytes32 ctx, bytes calldata staticInput, bytes calldata) + /// @inheritdoc IConditionalOrder + function generateOrder(address owner, address, bytes32 ctx, bytes calldata staticInput, bytes calldata) public view override returns (GPv2Order.Data memory order) { - /** - * @dev Decode the payload into a TWAP bundle and get the order. `orderFor` will revert if - * there is no current valid order. - * NOTE: This will return an order even if the part of the TWAP bundle that is currently - * valid is filled. This is safe as CoW Protocol ensures that each `orderUid` is only - * settled once. - */ TWAPOrder.Data memory twap = abi.decode(staticInput, (TWAPOrder.Data)); - /** - * @dev If `twap.t0` is set to 0, then get the start time from the context. - */ + // Get start time from cabinet if not specified if (twap.t0 == 0) { twap.t0 = uint256(composableCow.cabinet(owner, ctx)); } order = TWAPOrder.orderFor(twap); - /// @dev Revert if the order is outside the TWAP bundle's span. - if (!(block.timestamp <= order.validTo)) { - revert IConditionalOrder.OrderNotValid(NOT_WITHIN_SPAN); + // Check if outside the TWAP part's span + if (block.timestamp > order.validTo) { + // Calculate next part start time + uint256 part = TWAPOrderMathLib.currentPart(twap.t0, twap.t); + uint256 nextPartStart = twap.t0 + ((part + 1) * twap.t); + uint256 endTime = twap.t0 + (twap.n * twap.t); + + require(nextPartStart < endTime, IConditionalOrder.OrderNotValid(AFTER_TWAP_FINISH)); + revert IConditionalOrder.PollTryAtTimestamp(nextPartStart, NOT_WITHIN_SPAN); + } + } + + /// @inheritdoc IConditionalOrderGenerator + function getNextPollTimestamp(address owner, bytes32 ctx, bytes calldata staticInput, GPv2Order.Data memory) + external + view + override + returns (uint256) + { + TWAPOrder.Data memory twap = abi.decode(staticInput, (TWAPOrder.Data)); + + if (twap.t0 == 0) { + twap.t0 = uint256(composableCow.cabinet(owner, ctx)); + } + + uint256 part = TWAPOrderMathLib.currentPart(twap.t0, twap.t); + + // Last part - stop polling after this fills + if (part >= twap.n - 1) { + return POLL_NEVER; + } + + // Next part starts at... + return twap.t0 + ((part + 1) * twap.t); + } + + /// @inheritdoc IConditionalOrderGenerator + function describeOrder(address owner, bytes32 ctx, bytes calldata staticInput, GPv2Order.Data memory) + external + view + override + returns (string memory) + { + TWAPOrder.Data memory twap = abi.decode(staticInput, (TWAPOrder.Data)); + + if (twap.t0 == 0) { + twap.t0 = uint256(composableCow.cabinet(owner, ctx)); + } + + uint256 part = TWAPOrderMathLib.currentPart(twap.t0, twap.t); + + if (part >= twap.n - 1) { + return "final twap part"; } + return "twap part ready"; } } diff --git a/src/types/twap/libraries/TWAPOrder.sol b/src/types/twap/libraries/TWAPOrder.sol index 94e411a..bab919a 100644 --- a/src/types/twap/libraries/TWAPOrder.sol +++ b/src/types/twap/libraries/TWAPOrder.sol @@ -7,8 +7,6 @@ import {IERC20, GPv2Order} from "cowprotocol/contracts/libraries/GPv2Order.sol"; import {IConditionalOrder} from "../../../interfaces/IConditionalOrder.sol"; import {TWAPOrderMathLib} from "./TWAPOrderMathLib.sol"; -// --- error strings - string constant INVALID_SAME_TOKEN = "same token"; string constant INVALID_TOKEN = "invalid token"; string constant INVALID_PART_SELL_AMOUNT = "invalid part sell amount"; @@ -18,22 +16,17 @@ string constant INVALID_NUM_PARTS = "invalid num parts"; string constant INVALID_FREQUENCY = "invalid frequency"; string constant INVALID_SPAN = "invalid span"; -/** - * @title Time-weighted Average Order Library - * @author mfw78 - * @dev Structs, errors, and functions for time-weighted average orders. - */ +/// @title Time-weighted Average Order Library +/// @author mfw78 library TWAPOrder { using SafeCast for uint256; - // --- structs - struct Data { IERC20 sellToken; IERC20 buyToken; address receiver; - uint256 partSellAmount; // amount of sellToken to sell in each part - uint256 minPartLimit; // max price to pay for a unit of buyToken denominated in sellToken + uint256 partSellAmount; + uint256 minPartLimit; uint256 t0; uint256 n; uint256 t; @@ -41,38 +34,25 @@ library TWAPOrder { bytes32 appData; } - // --- functions - - /** - * @dev revert if the order is invalid - * @param self The TWAP order to validate - */ + /// @dev Revert if the order is invalid function validate(Data memory self) internal pure { - if (!(self.sellToken != self.buyToken)) revert IConditionalOrder.OrderNotValid(INVALID_SAME_TOKEN); - if (!(address(self.sellToken) != address(0) && address(self.buyToken) != address(0))) { - revert IConditionalOrder.OrderNotValid(INVALID_TOKEN); - } - if (!(self.partSellAmount > 0)) revert IConditionalOrder.OrderNotValid(INVALID_PART_SELL_AMOUNT); - if (!(self.minPartLimit > 0)) revert IConditionalOrder.OrderNotValid(INVALID_MIN_PART_LIMIT); - if (!(self.t0 < type(uint32).max)) revert IConditionalOrder.OrderNotValid(INVALID_START_TIME); - if (!(self.n > 1 && self.n <= type(uint32).max)) revert IConditionalOrder.OrderNotValid(INVALID_NUM_PARTS); - if (!(self.t > 0 && self.t <= 365 days)) revert IConditionalOrder.OrderNotValid(INVALID_FREQUENCY); - if (!(self.span <= self.t)) revert IConditionalOrder.OrderNotValid(INVALID_SPAN); + require(self.sellToken != self.buyToken, IConditionalOrder.OrderNotValid(INVALID_SAME_TOKEN)); + require( + address(self.sellToken) != address(0) && address(self.buyToken) != address(0), + IConditionalOrder.OrderNotValid(INVALID_TOKEN) + ); + require(self.partSellAmount > 0, IConditionalOrder.OrderNotValid(INVALID_PART_SELL_AMOUNT)); + require(self.minPartLimit > 0, IConditionalOrder.OrderNotValid(INVALID_MIN_PART_LIMIT)); + require(self.t0 < type(uint32).max, IConditionalOrder.OrderNotValid(INVALID_START_TIME)); + require(self.n > 1 && self.n <= type(uint32).max, IConditionalOrder.OrderNotValid(INVALID_NUM_PARTS)); + require(self.t > 0 && self.t <= 365 days, IConditionalOrder.OrderNotValid(INVALID_FREQUENCY)); + require(self.span <= self.t, IConditionalOrder.OrderNotValid(INVALID_SPAN)); } - /** - * @dev Generate the `GPv2Order` for the current part of the TWAP order. - * @param self The TWAP order to generate the order for. - * @return order The `GPv2Order` for the current part. - */ + /// @dev Generate the `GPv2Order` for the current part of the TWAP order. function orderFor(Data memory self) internal view returns (GPv2Order.Data memory order) { - // First, validate and revert if the TWAP is invalid. validate(self); - // Calculate the `validTo` timestamp for the order. This is unique for each part of the TWAP order. - // As `validTo` is unique, there is a corresponding unique `orderUid` for each `GPv2Order`. As - // CoWProtocol enforces that each `orderUid` is only used once, this means that each part of the TWAP - // order can only be executed once. order = GPv2Order.Data({ sellToken: self.sellToken, buyToken: self.buyToken, diff --git a/src/types/twap/libraries/TWAPOrderMathLib.sol b/src/types/twap/libraries/TWAPOrderMathLib.sol index a636776..9fbc00f 100644 --- a/src/types/twap/libraries/TWAPOrderMathLib.sol +++ b/src/types/twap/libraries/TWAPOrderMathLib.sol @@ -3,26 +3,18 @@ pragma solidity >=0.8.0 <0.9.0; import {IConditionalOrder} from "../../../interfaces/IConditionalOrder.sol"; -// --- error strings - -/// @dev No discrete order is valid before the start of the TWAP conditional order. string constant BEFORE_TWAP_START = "before twap start"; -/// @dev No discrete order is valid after it's last part. -string constant AFTER_TWAP_FINISH = "after twap finish"; +string constant AFTER_TWAP_FINISH = "twap finished"; -/** - * @title CoWProtocol TWAP Order Math Library - * @dev TWAP Math is separated to facilitate easier unit testing / SMT verification. - * @author mfw78 - */ +/// @title CoWProtocol TWAP Order Math Library +/// @author mfw78 +/// @dev TWAP math separated to facilitate easier unit testing / SMT verification. library TWAPOrderMathLib { - /** - * @dev Calculate the `validTo` timestamp for part of a TWAP order. - * @param startTime The start time of the TWAP order. - * @param numParts The number of parts to split the order into. - * @param frequency The frequency of each part (in seconds). - * @param span The span of each part (in seconds, or 0 for the whole epoch). - */ + /// @dev Calculate the `validTo` timestamp for part of a TWAP order. + /// @param startTime The start time of the TWAP order. + /// @param numParts The number of parts to split the order into. + /// @param frequency The frequency of each part (in seconds). + /// @param span The span of each part (in seconds, or 0 for the whole epoch). function calculateValidTo(uint256 startTime, uint256 numParts, uint256 frequency, uint256 span) internal view @@ -38,11 +30,13 @@ library TWAPOrderMathLib { assert(span <= frequency); unchecked { - /// @dev Order is not valid before the start (order commences at `t0`). - if (!(startTime <= block.timestamp)) revert IConditionalOrder.OrderNotValid(BEFORE_TWAP_START); + /** + * @dev Order is not valid before the start (order commences at `t0`). + */ + require(startTime <= block.timestamp, IConditionalOrder.PollTryAtTimestamp(startTime, BEFORE_TWAP_START)); /** - * @dev Order is expired after the last part (`n` parts, running at `t` time length). + * @dev Order is expired after the last part (`n` parts, running at `t` time length). * * Multiplication overflow: `numParts` is bounded by `type(uint32).max` and `frequency` is bounded by * `365 days` which is smaller than `type(uint32).max` so the product of `numParts * frequency` is @@ -50,9 +44,9 @@ library TWAPOrderMathLib { * Addition overflow: `startTime` is bounded by `block.timestamp` which is reasonably bounded by * `type(uint32).max` so the sum of `startTime + (numParts * frequency)` is ≈ 2⁵⁵. */ - if (!(block.timestamp < startTime + (numParts * frequency))) { - revert IConditionalOrder.OrderNotValid(AFTER_TWAP_FINISH); - } + require( + block.timestamp < startTime + (numParts * frequency), IConditionalOrder.OrderNotValid(AFTER_TWAP_FINISH) + ); /** * @dev We use integer division to get the part number as we want to round down to the nearest part. @@ -62,7 +56,8 @@ library TWAPOrderMathLib { * Divide by zero: `frequency` is asserted to be greater than zero. */ uint256 part = (block.timestamp - startTime) / frequency; - // calculate the `validTo` timestamp (inclusive as per `GPv2Order`) + + // Calculate the `validTo` timestamp (inclusive as per `GPv2Order`) if (span == 0) { /** * @dev If the span is zero, then the order is valid for the entire part. @@ -82,7 +77,7 @@ library TWAPOrderMathLib { } /** - * @dev If the span is non-zero, then the order is valid for the span of the part. + * @dev If the span is non-zero, then the order is valid for the span of the part. * * Multiplication overflow: `part` is bounded by `numParts` which is bounded by `type(uint32).max` with * `frequency` bounded by `365 days` which is smaller than `type(uint32).max` so the product of @@ -102,4 +97,12 @@ library TWAPOrderMathLib { */ } } + + /// @dev Calculate the current part number for a TWAP order. + /// @param startTime The start time of the TWAP order. + /// @param frequency The frequency of each part (in seconds). + function currentPart(uint256 startTime, uint256 frequency) internal view returns (uint256) { + if (block.timestamp < startTime) return 0; + return (block.timestamp - startTime) / frequency; + } } From b331169daaca40554bbe9ac7c1ffbb689aecc9c9 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 17:43:09 +0000 Subject: [PATCH 06/25] refactor: update StopLoss order type to new architecture - Extend BaseConditionalOrder instead of IConditionalOrderGenerator - Rename getTradeableOrder to generateOrder - Use require(condition, CustomError()) syntax - Use string constants for error messages - Override getNextPollTimestamp() returning POLL_NEVER for single-shot - Clean up documentation comments --- src/types/StopLoss.sol | 106 +++++++++++++++++++---------------------- 1 file changed, 48 insertions(+), 58 deletions(-) diff --git a/src/types/StopLoss.sol b/src/types/StopLoss.sol index 2ac6e0f..f594a06 100644 --- a/src/types/StopLoss.sol +++ b/src/types/StopLoss.sol @@ -1,47 +1,28 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; -import {IERC20, GPv2Order, IConditionalOrder, BaseConditionalOrder} from "../BaseConditionalOrder.sol"; +import { + IERC20, + GPv2Order, + IConditionalOrder, + IConditionalOrderGenerator, + BaseConditionalOrder +} from "../BaseConditionalOrder.sol"; import {IAggregatorV3Interface} from "../interfaces/IAggregatorV3Interface.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; -// --- error strings - -/// @dev Invalid price data returned by the oracle -string constant ORACLE_INVALID_PRICE = "oracle invalid price"; -/// @dev The oracle has returned stale data -string constant ORACLE_STALE_PRICE = "oracle stale price"; -/// @dev The strike price has not been reached string constant STRIKE_NOT_REACHED = "strike not reached"; -/// @dev The order is not valid anymore +string constant ORACLE_STALE_PRICE = "oracle stale price"; +string constant ORACLE_INVALID_PRICE = "oracle invalid price"; string constant ORDER_EXPIRED = "order expired"; -/** - * @title StopLoss conditional order - * Requires providing two price oracles (e.g. chainlink) and a strike price. If the sellToken price falls below the strike price, the order will be triggered - * @notice Both oracles need to be denominated in the same quote currency (e.g. GNO/ETH and USD/ETH for GNO/USD stop loss orders) - * @dev This order type has replay protection due to the `validTo` parameter, ensuring it will just execute one time - */ +/// @title StopLoss conditional order +/// @author mfw78 +/// @notice Triggers when sellToken price falls below strike price using Chainlink oracles. +/// @dev Both oracles must be denominated in the same quote currency. contract StopLoss is BaseConditionalOrder { - /// @dev Scaling factor for the strike price int256 constant SCALING_FACTOR = 10 ** 18; - /** - * Defines the parameters of a StopLoss order - * @param sellToken: the token to be sold - * @param buyToken: the token to be bought - * @param sellAmount: In case of a sell order, the exact amount of tokens the order is willing to sell. In case of a buy order, the maximium amount of tokens it is willing to sell - * @param buyAmount: In case of a sell order, the min amount of tokens the order is wants to receive. In case of a buy order, the exact amount of tokens it is willing to receive - * @param appData: The IPFS hash of the appData associated with the order - * @param receiver: The account that should receive the proceeds of the trade - * @param isSellOrder: Whether this is a sell or buy order - * @param isPartiallyFillable: Whether solvers are allowed to only fill a fraction of the order (useful if exact sell or buy amount isn't know at time of placement) - * @param validTo: The UNIX timestamp before which this order is valid - * @param sellTokenPriceOracle: A chainlink-like oracle returning the current sell token price in a given numeraire - * @param buyTokenPriceOracle: A chainlink-like oracle returning the current buy token price in the same numeraire - * @param strike: The exchange rate (denominated in sellToken/buyToken) which triggers the StopLoss order if the oracle price falls below. Specified in base / quote with 18 decimals. - * @param maxTimeSinceLastOracleUpdate: The maximum time since the last oracle update. If the oracle hasn't been updated in this time, the order will be considered invalid - */ struct Data { IERC20 sellToken; IERC20 buyToken; @@ -58,47 +39,36 @@ contract StopLoss is BaseConditionalOrder { uint256 maxTimeSinceLastOracleUpdate; } - function getTradeableOrder(address, address, bytes32, bytes calldata staticInput, bytes calldata) + /// @inheritdoc IConditionalOrder + function generateOrder(address, address, bytes32, bytes calldata staticInput, bytes calldata) public view override returns (GPv2Order.Data memory order) { Data memory data = abi.decode(staticInput, (Data)); - // scope variables to avoid stack too deep error + { - /// @dev Guard against expired orders - if (data.validTo < block.timestamp) { - revert IConditionalOrder.OrderNotValid(ORDER_EXPIRED); - } + require(data.validTo >= block.timestamp, IConditionalOrder.OrderNotValid(ORDER_EXPIRED)); (, int256 basePrice,, uint256 sellUpdatedAt,) = data.sellTokenPriceOracle.latestRoundData(); (, int256 quotePrice,, uint256 buyUpdatedAt,) = data.buyTokenPriceOracle.latestRoundData(); - /// @dev Guard against invalid price data - if (!(basePrice > 0 && quotePrice > 0)) { - revert IConditionalOrder.OrderNotValid(ORACLE_INVALID_PRICE); - } + require(basePrice > 0 && quotePrice > 0, IConditionalOrder.OrderNotValid(ORACLE_INVALID_PRICE)); - /// @dev Guard against stale data at a user-specified interval. The maxTimeSinceLastOracleUpdate should at least exceed the both oracles' update intervals. - if ( - !( - sellUpdatedAt >= block.timestamp - data.maxTimeSinceLastOracleUpdate - && buyUpdatedAt >= block.timestamp - data.maxTimeSinceLastOracleUpdate - ) - ) { - revert IConditionalOrder.PollTryNextBlock(ORACLE_STALE_PRICE); - } + require( + sellUpdatedAt >= block.timestamp - data.maxTimeSinceLastOracleUpdate + && buyUpdatedAt >= block.timestamp - data.maxTimeSinceLastOracleUpdate, + IConditionalOrder.PollTryNextBlock(ORACLE_STALE_PRICE) + ); - // Normalize the decimals for basePrice and quotePrice, scaling them to 18 decimals - // Caution: Ensure that base and quote have the same numeraires (e.g. both are denominated in USD) basePrice = Utils.scalePrice(basePrice, data.sellTokenPriceOracle.decimals(), 18); quotePrice = Utils.scalePrice(quotePrice, data.buyTokenPriceOracle.decimals(), 18); - /// @dev Scale the strike price to 18 decimals. - if (!(basePrice * SCALING_FACTOR / quotePrice <= data.strike)) { - revert IConditionalOrder.PollTryNextBlock(STRIKE_NOT_REACHED); - } + require( + basePrice * SCALING_FACTOR / quotePrice <= data.strike, + IConditionalOrder.PollTryNextBlock(STRIKE_NOT_REACHED) + ); } order = GPv2Order.Data( @@ -109,11 +79,31 @@ contract StopLoss is BaseConditionalOrder { data.buyAmount, data.validTo, data.appData, - 0, // use zero fee for limit orders + 0, data.isSellOrder ? GPv2Order.KIND_SELL : GPv2Order.KIND_BUY, data.isPartiallyFillable, GPv2Order.BALANCE_ERC20, GPv2Order.BALANCE_ERC20 ); } + + /// @inheritdoc IConditionalOrderGenerator + function getNextPollTimestamp(address, bytes32, bytes calldata, GPv2Order.Data memory) + external + pure + override + returns (uint256) + { + return POLL_NEVER; // Single-shot order + } + + /// @inheritdoc IConditionalOrderGenerator + function describeOrder(address, bytes32, bytes calldata, GPv2Order.Data memory) + external + pure + override + returns (string memory) + { + return "stop-loss triggered"; + } } From 6dcfe83a669b306663a2eedabce1d5d5f5065b18 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 17:46:04 +0000 Subject: [PATCH 07/25] refactor: update GoodAfterTime order type to new architecture - Extend BaseConditionalOrder instead of IConditionalOrderGenerator - Rename getTradeableOrder to generateOrder - Use require(condition, CustomError()) syntax - Use string constants for error messages - Override getNextPollTimestamp() returning POLL_NEVER for single-shot - Clean up documentation comments --- src/types/GoodAfterTime.sol | 98 +++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/src/types/GoodAfterTime.sol b/src/types/GoodAfterTime.sol index abae962..7941afb 100644 --- a/src/types/GoodAfterTime.sol +++ b/src/types/GoodAfterTime.sol @@ -4,41 +4,33 @@ pragma solidity >=0.8.0 <0.9.0; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {IExpectedOutCalculator} from "../vendored/Milkman.sol"; -import {IERC20, IConditionalOrder, GPv2Order, BaseConditionalOrder} from "../BaseConditionalOrder.sol"; +import { + IERC20, + IConditionalOrder, + IConditionalOrderGenerator, + GPv2Order, + BaseConditionalOrder +} from "../BaseConditionalOrder.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; -// --- error strings -/// @dev If the trade is called before the time it becomes valid. string constant TOO_EARLY = "too early"; -/// @dev If the sell token balance is below the minimum. string constant BALANCE_INSUFFICIENT = "balance insufficient"; -/// @dev If the price checker fails. string constant PRICE_CHECKER_FAILED = "price checker failed"; -/** - * @title Good After Time (GAT) Conditional Order - with Milkman price checkers - * @author mfw78 - * @dev Designed to be used with the CoW Protocol Conditional Order Framework. - * This order type allows for placing an order that is valid after a certain time - * and that has an optional minimum `sellAmount` determined by a price checker. The - * actual `buyAmount` is determined by off chain input. As changing the `buyAmount` - * changes the `orderUid` of the order, this allows for placing multiple orders. To - * ensure that the order is not filled multiple times, a `minSellBalance` is - * checked before the order is placed. - */ +/// @title Good After Time (GAT) Conditional Order +/// @author mfw78 +/// @notice Order valid after a certain time with optional Milkman price checking. contract GoodAfterTime is BaseConditionalOrder { using SafeCast for uint256; - // --- types - struct Data { IERC20 sellToken; IERC20 buyToken; address receiver; - uint256 sellAmount; // buy amount comes from offchainInput + uint256 sellAmount; uint256 minSellBalance; - uint256 startTime; // when the order becomes valid - uint256 endTime; // when the order expires + uint256 startTime; + uint256 endTime; bool allowPartialFill; bytes priceCheckerPayload; bytes32 appData; @@ -47,43 +39,35 @@ contract GoodAfterTime is BaseConditionalOrder { struct PriceCheckerPayload { IExpectedOutCalculator checker; bytes payload; - uint256 allowedSlippage; // in basis points + uint256 allowedSlippage; } - function getTradeableOrder( - address owner, - address, - bytes32, - bytes calldata staticInput, - bytes calldata offchainInput - ) public view override returns (GPv2Order.Data memory order) { - // Decode the payload into the good after time parameters. + /// @inheritdoc IConditionalOrder + function generateOrder(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)); - // Don't allow the order to be placed before it becomes valid. - if (!(block.timestamp >= data.startTime)) { - revert IConditionalOrder.PollTryAtEpoch(data.startTime, TOO_EARLY); - } + require(block.timestamp >= data.startTime, IConditionalOrder.PollTryAtTimestamp(data.startTime, TOO_EARLY)); - // Require that the sell token balance is above the minimum. - if (!(data.sellToken.balanceOf(owner) >= data.minSellBalance)) { - revert IConditionalOrder.OrderNotValid(BALANCE_INSUFFICIENT); - } + require( + data.sellToken.balanceOf(owner) >= data.minSellBalance, + IConditionalOrder.OrderNotValid(BALANCE_INSUFFICIENT) + ); uint256 buyAmount = abi.decode(offchainInput, (uint256)); - // Optionally check the price checker. if (data.priceCheckerPayload.length > 0) { - // Decode the payload into the price checker parameters. PriceCheckerPayload memory p = abi.decode(data.priceCheckerPayload, (PriceCheckerPayload)); - - // Get the expected out from the price checker. uint256 _expectedOut = p.checker.getExpectedOut(data.sellAmount, data.sellToken, data.buyToken, p.payload); - // Don't allow the order to be placed if the buyAmount is less than the minimum out. - if (!(buyAmount >= (_expectedOut * (Utils.MAX_BPS - p.allowedSlippage)) / Utils.MAX_BPS)) { - revert IConditionalOrder.PollTryNextBlock(PRICE_CHECKER_FAILED); - } + require( + buyAmount >= (_expectedOut * (Utils.MAX_BPS - p.allowedSlippage)) / Utils.MAX_BPS, + IConditionalOrder.PollTryNextBlock(PRICE_CHECKER_FAILED) + ); } order = GPv2Order.Data( @@ -94,11 +78,31 @@ contract GoodAfterTime is BaseConditionalOrder { buyAmount, data.endTime.toUint32(), data.appData, - 0, // use zero fee for limit orders + 0, GPv2Order.KIND_SELL, data.allowPartialFill, GPv2Order.BALANCE_ERC20, GPv2Order.BALANCE_ERC20 ); } + + /// @inheritdoc IConditionalOrderGenerator + function getNextPollTimestamp(address, bytes32, bytes calldata, GPv2Order.Data memory) + external + pure + override + returns (uint256) + { + return POLL_NEVER; // Single-shot within time window + } + + /// @inheritdoc IConditionalOrderGenerator + function describeOrder(address, bytes32, bytes calldata, GPv2Order.Data memory) + external + pure + override + returns (string memory) + { + return "good-after-time order ready"; + } } From 92d008970f805b31adae39f88cecc2fc6d09f001 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 18:03:48 +0000 Subject: [PATCH 08/25] refactor: update TradeAboveThreshold order type to new architecture - Extend BaseConditionalOrder instead of IConditionalOrderGenerator - Rename getTradeableOrder to generateOrder - Use require(condition, CustomError()) syntax - Use string constants for error messages - Clean up documentation comments --- src/types/TradeAboveThreshold.sol | 41 +++++++++---------------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/src/types/TradeAboveThreshold.sol b/src/types/TradeAboveThreshold.sol index df815e5..cefd638 100644 --- a/src/types/TradeAboveThreshold.sol +++ b/src/types/TradeAboveThreshold.sol @@ -1,23 +1,13 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.0 <0.9.0; -import { - IERC20, - GPv2Order, - IConditionalOrder, - IConditionalOrderGenerator, - BaseConditionalOrder -} from "../BaseConditionalOrder.sol"; +import {IERC20, GPv2Order, IConditionalOrder, BaseConditionalOrder} from "../BaseConditionalOrder.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; +import {BALANCE_INSUFFICIENT} from "./GoodAfterTime.sol"; -// --- error strings - -/// @dev The sell token balance is below the threshold (ie. threshold not met). -string constant BALANCE_INSUFFICIENT = "balance insufficient"; - -/** - * @title A smart contract that trades whenever its balance of a certain token exceeds a target threshold - */ +/// @title TradeAboveThreshold - Trades when balance exceeds threshold +/// @author mfw78 +/// @notice Sells entire balance when it exceeds the specified threshold. contract TradeAboveThreshold is BaseConditionalOrder { struct Data { IERC20 sellToken; @@ -28,32 +18,24 @@ contract TradeAboveThreshold is BaseConditionalOrder { bytes32 appData; } - /** - * @inheritdoc IConditionalOrderGenerator - * @dev If the `owner`'s balance of `sellToken` is above the specified threshold, sell its entire balance - * for `buyToken` at the current market price (no limit!). - */ - function getTradeableOrder(address owner, address, bytes32, bytes calldata staticInput, bytes calldata) + /// @inheritdoc IConditionalOrder + function generateOrder(address owner, address, bytes32, bytes calldata staticInput, bytes calldata) public view override returns (GPv2Order.Data memory order) { - /// @dev Decode the payload into the trade above threshold parameters. - TradeAboveThreshold.Data memory data = abi.decode(staticInput, (Data)); + Data memory data = abi.decode(staticInput, (Data)); uint256 balance = data.sellToken.balanceOf(owner); - // Don't allow the order to be placed if the balance is less than the threshold. - if (!(balance >= data.threshold)) { - revert IConditionalOrder.PollTryNextBlock(BALANCE_INSUFFICIENT); - } - // ensures that orders queried shortly after one another result in the same hash (to avoid spamming the orderbook) + require(balance >= data.threshold, IConditionalOrder.PollTryNextBlock(BALANCE_INSUFFICIENT)); + order = GPv2Order.Data( data.sellToken, data.buyToken, data.receiver, balance, - 1, // 0 buy amount is not allowed + 1, Utils.validToBucket(data.validityBucketSeconds), data.appData, 0, @@ -63,4 +45,5 @@ contract TradeAboveThreshold is BaseConditionalOrder { GPv2Order.BALANCE_ERC20 ); } + // Uses default getNextPollTimestamp() and describeOrder() from BaseConditionalOrder } From 36c53a2c12673f15815d5d87b2ed88e6accb0cb8 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 18:12:33 +0000 Subject: [PATCH 09/25] refactor: update PerpetualStableSwap order type to new architecture - Extend BaseConditionalOrder instead of IConditionalOrderGenerator - Rename getTradeableOrder to generateOrder - Use require(condition, CustomError()) syntax - Use string constants for error messages - Clean up documentation comments --- src/types/PerpetualStableSwap.sol | 62 ++++++++----------------------- 1 file changed, 15 insertions(+), 47 deletions(-) diff --git a/src/types/PerpetualStableSwap.sol b/src/types/PerpetualStableSwap.sol index c20076a..f9e02ae 100644 --- a/src/types/PerpetualStableSwap.sol +++ b/src/types/PerpetualStableSwap.sol @@ -1,36 +1,16 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.0 <0.9.0; -import { - IERC20, - GPv2Order, - IConditionalOrder, - IConditionalOrderGenerator, - BaseConditionalOrder -} from "../BaseConditionalOrder.sol"; +import {IERC20, GPv2Order, IConditionalOrder, BaseConditionalOrder} from "../BaseConditionalOrder.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; -// --- error strings -/// @dev The sell amount is insufficient (ie. not funded). -string constant NOT_FUNDED = "not funded"; - -/** - * @title A smart contract that is always willing to trade between tokenA and tokenB 1:1, - * taking decimals into account (and adding specifiable spread) - */ +/// @title PerpetualStableSwap - 1:1 swaps between token pairs with spread +/// @author mfw78 +/// @notice Always willing to trade between tokenA and tokenB at 1:1 (adjusted for decimals) plus spread. contract PerpetualStableSwap is BaseConditionalOrder { - /** - * Creates a new perpetual swap order. All resulting swaps will be made from the target contract. - * @param tokenA One of the two tokens that can be perpetually swapped against one another - * @param tokenB The other of the two tokens that can be perpetually swapped against one another - * @param validityBucketSeconds The width of the validity bucket in seconds - * @param halfSpreadBps The markup to parity (ie 1:1 exchange rate) that is charged for each swap - * @param appData Arbitrary data that will be passed to the app when the order is settled - */ struct Data { IERC20 tokenA; IERC20 tokenB; - // don't include a receiver as it will always be self (ie. owner of this order) uint32 validityBucketSeconds; uint256 halfSpreadBps; bytes32 appData; @@ -43,34 +23,23 @@ contract PerpetualStableSwap is BaseConditionalOrder { uint256 buyAmount; } - /** - * @inheritdoc IConditionalOrderGenerator - */ - function getTradeableOrder(address owner, address, bytes32, bytes calldata staticInput, bytes calldata) + /// @inheritdoc IConditionalOrder + function generateOrder(address owner, address, bytes32, bytes calldata staticInput, bytes calldata) public view override returns (GPv2Order.Data memory order) { - /// @dev Decode the payload into the perpetual stable swap parameters. - PerpetualStableSwap.Data memory data = abi.decode(staticInput, (Data)); + Data memory data = abi.decode(staticInput, (Data)); - // Always sell whatever of the two tokens we have more of BuySellData memory buySellData = side(owner, data); - // Make sure the order is funded, otherwise it is not valid - if (!(buySellData.sellAmount > 0)) { - revert IConditionalOrder.OrderNotValid(NOT_FUNDED); - } + require(buySellData.sellAmount > 0, IConditionalOrder.OrderNotValid("not funded")); - // Unless spread is 0 (and there is no surplus), order collision is not an issue as sell and buy amounts should - // increase for each subsequent order. We therefore set validity to a large time span - // Note, that reducing current block to a common start time is needed so that the order returned here - // does not change between the time it is queried and the time it is settled. Validity should be between 1 & 2 weeks. order = GPv2Order.Data( buySellData.sellToken, buySellData.buyToken, - address(0), // special case to refer to 'self' as the receiver per `GPv2Order.sol` library. + address(0), buySellData.sellAmount, buySellData.buyAmount, Utils.validToBucket(data.validityBucketSeconds), @@ -82,12 +51,9 @@ contract PerpetualStableSwap is BaseConditionalOrder { GPv2Order.BALANCE_ERC20 ); } + // Uses default getNextPollTimestamp() and describeOrder() from BaseConditionalOrder - function side(address owner, PerpetualStableSwap.Data memory data) - internal - view - returns (BuySellData memory buySellData) - { + function side(address owner, Data memory data) internal view returns (BuySellData memory buySellData) { IERC20 tokenA = IERC20(address(data.tokenA)); IERC20 tokenB = IERC20(address(data.tokenB)); uint256 balanceA = tokenA.balanceOf(owner); @@ -98,14 +64,16 @@ contract PerpetualStableSwap is BaseConditionalOrder { sellToken: tokenA, buyToken: tokenB, sellAmount: balanceA, - buyAmount: convertAmount(tokenA, balanceA, tokenB) * (Utils.MAX_BPS + data.halfSpreadBps) / Utils.MAX_BPS + buyAmount: convertAmount(tokenA, balanceA, tokenB) * (Utils.MAX_BPS + data.halfSpreadBps) + / Utils.MAX_BPS }); } else { buySellData = BuySellData({ sellToken: tokenB, buyToken: tokenA, sellAmount: balanceB, - buyAmount: convertAmount(tokenB, balanceB, tokenA) * (Utils.MAX_BPS + data.halfSpreadBps) / Utils.MAX_BPS + buyAmount: convertAmount(tokenB, balanceB, tokenA) * (Utils.MAX_BPS + data.halfSpreadBps) + / Utils.MAX_BPS }); } } From 23d44a741659ac89887cfc17fad4d3dac3846c6d Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 18:12:51 +0000 Subject: [PATCH 10/25] feat: update ComposableCoW for dual-path support with fill detection - Refactor isValidSafeSignature to call handler.verify() directly - Refactor getTradeableOrderWithSignature to use handler.poll() - Add fill detection via GPv2Settlement.filledAmount() - Return PARTIALLY_FILLED or FILLED based on order kind (sell/buy) - Add checkOrder() for quick tradeable status check - Store settlement contract reference for fill queries - Use require(condition, CustomError()) syntax - Clean up documentation comments --- src/ComposableCoW.sol | 304 +++++++++++++++++------------------------- 1 file changed, 124 insertions(+), 180 deletions(-) diff --git a/src/ComposableCoW.sol b/src/ComposableCoW.sol index 6aaa8b6..989c333 100644 --- a/src/ComposableCoW.sol +++ b/src/ComposableCoW.sol @@ -15,13 +15,11 @@ import {ISwapGuard} from "./interfaces/ISwapGuard.sol"; import {IValueFactory} from "./interfaces/IValueFactory.sol"; import {CoWSettlement} from "./vendored/CoWSettlement.sol"; -/** - * @title ComposableCoW - A contract that allows users to create multiple conditional orders - * @author mfw78 - * @dev Designed to be used with Safe + ExtensibleFallbackHandler - */ +/// @title ComposableCoW - Conditional order framework for CoW Protocol +/// @author mfw78 +/// @notice Enables Safe wallets to create conditional orders with dual-path verification. +/// @dev Settlement path (isValidSafeSignature) is gas-optimized; polling path returns rich metadata. contract ComposableCoW is ISafeSignatureVerifier { - // --- errors error ProofNotAuthed(); error SingleOrderNotAuthed(); error SwapGuardRestricted(); @@ -29,104 +27,53 @@ contract ComposableCoW is ISafeSignatureVerifier { error InvalidFallbackHandler(); error InterfaceNotSupported(); - // --- types - - // A struct to encapsulate order parameters / offchain input struct PayloadStruct { bytes32[] proof; IConditionalOrder.ConditionalOrderParams params; bytes offchainInput; } - // A struct representing where to find the proofs struct Proof { uint256 location; bytes data; } - // --- events - - // An event emitted when a user sets their merkle root event MerkleRootSet(address indexed owner, bytes32 root, Proof proof); event ConditionalOrderCreated(address indexed owner, IConditionalOrder.ConditionalOrderParams params); event SwapGuardSet(address indexed owner, ISwapGuard swapGuard); - // --- state - // Domain separator is only used for generating signatures + CoWSettlement public immutable settlement; bytes32 public immutable domainSeparator; - /// @dev Mapping of owner's merkle roots mapping(address => bytes32) public roots; - /// @dev Mapping of owner's single orders mapping(address => mapping(bytes32 => bool)) public singleOrders; - // @dev Mapping of owner's swap guard mapping(address => ISwapGuard) public swapGuards; - // @dev Mapping of owner's on-chain storage slots mapping(address => mapping(bytes32 => bytes32)) public cabinet; - // --- constructor - - /** - * @param _settlement The GPv2 settlement contract - */ constructor(address _settlement) { - domainSeparator = CoWSettlement(_settlement).domainSeparator(); + settlement = CoWSettlement(_settlement); + domainSeparator = settlement.domainSeparator(); } - // --- setters - - /** - * Set the merkle root of the user's conditional orders - * @notice Set the merkle root of the user's conditional orders - * @param root The merkle root of the user's conditional orders - * @param proof Where to find the proofs - */ function setRoot(bytes32 root, Proof calldata proof) public { roots[msg.sender] = root; emit MerkleRootSet(msg.sender, root, proof); } - /** - * Set the merkle root of the user's conditional orders and store a value from on-chain in the cabinet - * @param root The merkle root of the user's conditional orders - * @param proof Where to find the proofs - * @param factory A factory from which to get a value to store in the cabinet related to the merkle root - * @param data Implementation specific off-chain data - */ function setRootWithContext(bytes32 root, Proof calldata proof, IValueFactory factory, bytes calldata data) external { setRoot(root, proof); - - // Default to the zero slot for a merkle root as this is the most common use case - // and should save gas on calldata when reading the cabinet. - - // Set the cabinet slot cabinet[msg.sender][bytes32(0)] = factory.getValue(data); } - /** - * Authorise a single conditional order - * @param params The parameters of the conditional order - * @param dispatch Whether to dispatch the `ConditionalOrderCreated` event - */ function create(IConditionalOrder.ConditionalOrderParams calldata params, bool dispatch) public { - if (!(address(params.handler) != address(0))) { - revert InvalidHandler(); - } - + require(address(params.handler) != address(0), InvalidHandler()); singleOrders[msg.sender][hash(params)] = true; if (dispatch) { emit ConditionalOrderCreated(msg.sender, params); } } - /** - * Authorise a single conditional order and store a value from on-chain in the cabinet - * @param params The parameters of the conditional order - * @param factory A factory from which to get a value to store in the cabinet - * @param data Implementation specific off-chain data - * @param dispatch Whether to dispatch the `ConditionalOrderCreated` event - */ function createWithContext( IConditionalOrder.ConditionalOrderParams calldata params, IValueFactory factory, @@ -134,189 +81,158 @@ contract ComposableCoW is ISafeSignatureVerifier { bool dispatch ) external { create(params, dispatch); - - // When setting the slot, an opinionated direction is taken to tie the return value of - // the slot to the conditional order, such that there is a guarantee or data integrity - - // Set the cabinet slot cabinet[msg.sender][hash(params)] = factory.getValue(data); } - /** - * Remove the authorisation of a single conditional order - * @param singleOrderHash The hash of the single conditional order to remove - */ function remove(bytes32 singleOrderHash) external { singleOrders[msg.sender][singleOrderHash] = false; cabinet[msg.sender][singleOrderHash] = bytes32(0); } - /** - * Set the swap guard of the user's conditional orders - * @param swapGuard The address of the swap guard - */ function setSwapGuard(ISwapGuard swapGuard) external { swapGuards[msg.sender] = swapGuard; emit SwapGuardSet(msg.sender, swapGuard); } - // --- ISafeSignatureVerifier - - /** - * @inheritdoc ISafeSignatureVerifier - * @dev This function does not make use of the `typeHash` parameter as CoW Protocol does not - * have more than one type. - * @param encodeData Is the abi encoded `GPv2Order.Data` - * @param payload Is the abi encoded `PayloadStruct` - */ + /// @inheritdoc ISafeSignatureVerifier + /// @dev Gas-sensitive settlement path. Calls handler.verify() directly. function isValidSafeSignature( Safe safe, address sender, bytes32 _hash, bytes32 _domainSeparator, - bytes32, // typeHash + bytes32, bytes calldata encodeData, bytes calldata payload ) external view override returns (bytes4 magic) { - // First decode the payload PayloadStruct memory _payload = abi.decode(payload, (PayloadStruct)); - - // Check if the order is authorised bytes32 ctx = _auth(address(safe), _payload.params, _payload.proof); - // It's an authorised order, validate it. GPv2Order.Data memory order = abi.decode(encodeData, (GPv2Order.Data)); - // Check with the swap guard if the order is restricted or not - if (!(_guardCheck(address(safe), ctx, _payload.params, _payload.offchainInput, order))) { - revert SwapGuardRestricted(); - } - - // Proof is valid, guard (if any) is valid, now check the handler - _payload.params.handler.verify( - address(safe), - sender, - _hash, - _domainSeparator, - ctx, - _payload.params.staticInput, - _payload.offchainInput, - order - ); + require(_guardCheck(address(safe), ctx, _payload.params, _payload.offchainInput, order), SwapGuardRestricted()); + + _payload.params.handler + .verify( + address(safe), + sender, + _hash, + _domainSeparator, + ctx, + _payload.params.staticInput, + _payload.offchainInput, + order + ); return ERC1271.isValidSignature.selector; } - // --- getters - - /** - * Get the `GPv2Order.Data` and signature for submitting to CoW Protocol API - * @param owner of the order - * @param params `ConditionalOrderParams` for the order - * @param offchainInput any dynamic off-chain input for generating the discrete order - * @param proof if using merkle-roots that H(handler || salt || staticInput) is in the merkle tree - * @return order discrete order for submitting to CoW Protocol API - * @return signature for submitting to CoW Protocol API - */ + /// @notice Poll for a tradeable order with signature and scheduling metadata + /// @dev Returns structured result - never reverts for order conditions. + /// @param owner The Safe/wallet that owns the order + /// @param params The conditional order parameters + /// @param offchainInput Dynamic input from watch-tower + /// @param proof Merkle proof (empty for single orders) + /// @return result Structured polling result with order (if ready) and hints + /// @return signature EIP-1271 signature (empty if order not ready) function getTradeableOrderWithSignature( address owner, IConditionalOrder.ConditionalOrderParams calldata params, bytes calldata offchainInput, bytes32[] calldata proof - ) external view returns (GPv2Order.Data memory order, bytes memory signature) { - // Check if the order is authorised and in doing so, get the context + ) external view returns (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) { bytes32 ctx = _auth(owner, params, proof); - // Make sure the handler supports `IConditionalOrderGenerator` - try IConditionalOrderGenerator(address(params.handler)).supportsInterface( - type(IConditionalOrderGenerator).interfaceId - ) returns (bool supported) { - if (!supported) { - revert InterfaceNotSupported(); - } + // Verify handler supports IConditionalOrderGenerator + try IConditionalOrderGenerator(address(params.handler)) + .supportsInterface(type(IConditionalOrderGenerator).interfaceId) returns ( + bool supported + ) { + require(supported, InterfaceNotSupported()); } catch { revert InterfaceNotSupported(); } - order = IConditionalOrderGenerator(address(params.handler)).getTradeableOrder( - owner, msg.sender, ctx, params.staticInput, offchainInput - ); + // Call poll() for structured result + result = IConditionalOrderGenerator(address(params.handler)) + .poll(owner, msg.sender, ctx, params.staticInput, offchainInput); - // Check with the swap guard if the order is restricted or not - if (!(_guardCheck(owner, ctx, params, offchainInput, order))) { - revert SwapGuardRestricted(); + // Only build signature for SUCCESS + if (result.code != IConditionalOrderGenerator.PollResultCode.SUCCESS) { + return (result, ""); } - try ExtensibleFallbackHandler(owner).supportsInterface(type(ISignatureVerifierMuxer).interfaceId) returns ( - bool supported - ) { - if (!supported) { - revert InvalidFallbackHandler(); - } - signature = abi.encodeWithSignature( - "safeSignature(bytes32,bytes32,bytes,bytes)", - domainSeparator, - GPv2Order.TYPE_HASH, - abi.encode(order), - abi.encode(PayloadStruct({params: params, offchainInput: offchainInput, proof: proof})) - ); - } catch { - // Assume that this is the EIP-1271 Forwarder (which does not have a `NAME` function) - // The default signature is the abi.encode of the tuple (order, payload) - signature = abi.encode(order, PayloadStruct({params: params, offchainInput: offchainInput, proof: proof})); + // Check if order has already been filled (partially or fully) + uint256 filledAmount = _getFilledAmount(owner, result.order); + if (filledAmount > 0) { + // For sell orders, compare against sellAmount; for buy orders, compare against buyAmount + uint256 totalAmount = + result.order.kind == GPv2Order.KIND_SELL ? result.order.sellAmount : result.order.buyAmount; + bool isFullyFilled = filledAmount >= totalAmount; + result = IConditionalOrderGenerator.PollResult({ + code: isFullyFilled + ? IConditionalOrderGenerator.PollResultCode.FILLED + : IConditionalOrderGenerator.PollResultCode.PARTIALLY_FILLED, + order: result.order, + nextPollTimestamp: result.nextPollTimestamp, + waitUntil: 0, + reason: isFullyFilled ? "order fully filled" : "order partially filled", + filledAmount: filledAmount + }); + return (result, ""); + } + + // Check swap guard + if (!_guardCheck(owner, ctx, params, offchainInput, result.order)) { + result = IConditionalOrderGenerator.PollResult({ + code: IConditionalOrderGenerator.PollResultCode.INVALID, + order: result.order, + nextPollTimestamp: 0, + waitUntil: 0, + reason: "swap guard restricted", + filledAmount: 0 + }); + return (result, ""); } + + signature = _buildSignature(owner, params, offchainInput, proof, result.order); } - // --- helper viewer functions + /// @notice Quick check if an order is currently tradeable + /// @return code The poll result code + /// @return waitUntil For WAIT_* codes, when to retry + function checkOrder( + address owner, + IConditionalOrder.ConditionalOrderParams calldata params, + bytes calldata offchainInput, + bytes32[] calldata proof + ) external view returns (IConditionalOrderGenerator.PollResultCode code, uint256 waitUntil) { + bytes32 ctx = _auth(owner, params, proof); + + IConditionalOrderGenerator.PollResult memory result = IConditionalOrderGenerator(address(params.handler)) + .poll(owner, msg.sender, ctx, params.staticInput, offchainInput); + + return (result.code, result.waitUntil); + } - /** - * Return the hash of the conditional order parameters - * @param params `ConditionalOrderParams` for the order - * @return hash of the conditional order parameters - */ function hash(IConditionalOrder.ConditionalOrderParams memory params) public pure returns (bytes32) { return keccak256(abi.encode(params)); } - // --- internal functions - - /** - * Check if the order has been authorised by the owner - * @dev If `proof.length == 0`, then we use the single order auth - * @param owner of the order whose authorisation is being checked - * @param params that uniquely identify the order - * @param proof to assert that H(params) is in the merkle tree (optional) - */ function _auth(address owner, IConditionalOrder.ConditionalOrderParams memory params, bytes32[] memory proof) internal view returns (bytes32 ctx) { if (proof.length != 0) { - /// @dev Computing proof using leaf double hashing bytes32 leaf = keccak256(bytes.concat(hash(params))); - - // Check if the proof is valid - if (!MerkleProof.verify(proof, roots[owner], leaf)) { - revert ProofNotAuthed(); - } + require(MerkleProof.verify(proof, roots[owner], leaf), ProofNotAuthed()); } else { - // Check if the order is authorised ctx = hash(params); - if (!singleOrders[owner][ctx]) { - revert SingleOrderNotAuthed(); - } + require(singleOrders[owner][ctx], SingleOrderNotAuthed()); } } - /** - * Check the swap guard if the order is restricted or not - * @param owner who's swap guard to check - * @param ctx of the order (bytes32(0) if a merkle tree is used, otherwise H(params)) - * @param params that uniquely identify the order - * @param offchainInput that has been proposed by `sender` - * @param order GPv2Order.Data that has been proposed by `sender` - */ function _guardCheck( address owner, bytes32 ctx, @@ -330,4 +246,32 @@ contract ComposableCoW is ISafeSignatureVerifier { } return true; } + + function _buildSignature( + address owner, + IConditionalOrder.ConditionalOrderParams calldata params, + bytes calldata offchainInput, + bytes32[] calldata proof, + GPv2Order.Data memory order + ) internal view returns (bytes memory signature) { + try ExtensibleFallbackHandler(owner).supportsInterface(type(ISignatureVerifierMuxer).interfaceId) returns ( + bool supported + ) { + require(supported, InvalidFallbackHandler()); + signature = abi.encodeWithSignature( + "safeSignature(bytes32,bytes32,bytes,bytes)", + domainSeparator, + GPv2Order.TYPE_HASH, + abi.encode(order), + abi.encode(PayloadStruct({params: params, offchainInput: offchainInput, proof: proof})) + ); + } catch { + signature = abi.encode(order, PayloadStruct({params: params, offchainInput: offchainInput, proof: proof})); + } + } + + function _getFilledAmount(address owner, GPv2Order.Data memory order) internal view returns (uint256) { + bytes memory orderUid = abi.encodePacked(GPv2Order.hash(order, domainSeparator), owner, order.validTo); + return settlement.filledAmount(orderUid); + } } From 3b79e1b16526bfaefb67fd9b9e9a5f368262feeb Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 18:13:41 +0000 Subject: [PATCH 11/25] chore: clean up deploy script Remove unused comments. --- script/deploy_AnvilStack.s.sol | 3 --- 1 file changed, 3 deletions(-) diff --git a/script/deploy_AnvilStack.s.sol b/script/deploy_AnvilStack.s.sol index 0b59a6f..56aca40 100644 --- a/script/deploy_AnvilStack.s.sol +++ b/script/deploy_AnvilStack.s.sol @@ -27,16 +27,13 @@ import {PerpetualStableSwap} from "../src/types/PerpetualStableSwap.sol"; import {TradeAboveThreshold} from "../src/types/TradeAboveThreshold.sol"; contract DeployAnvilStack is Script { - // --- constants uint256 constant PAUSE_WINDOW_DURATION = 7776000; uint256 constant BUFFER_PERIOD_DURATION = 2592000; - // --- cow protocol contract stack IVault public vault; GPv2Settlement public settlement; address public relayer; - // --- safe contract stack Safe public singleton; SafeProxyFactory public factory; CompatibilityFallbackHandler public handler; From e771541bf86663cf7a1d41d4fbbd873d5f51a98e Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 18:34:08 +0000 Subject: [PATCH 12/25] test: update test base infrastructure - Clean up unused imports and comments - Update for new interface signatures --- test/Base.t.sol | 1 - test/helpers/CoWProtocol.t.sol | 3 --- test/libraries/ComposableCoWLib.t.sol | 4 ++-- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/test/Base.t.sol b/test/Base.t.sol index 056f7ed..1fe2219 100644 --- a/test/Base.t.sol +++ b/test/Base.t.sol @@ -12,7 +12,6 @@ abstract contract Base is Test, SafeHelper, CoWProtocol { using TestAccountLib for TestAccount[]; using TestAccountLib for TestAccount; - // --- accounts TestAccount alice; TestAccount bob; TestAccount carol; diff --git a/test/helpers/CoWProtocol.t.sol b/test/helpers/CoWProtocol.t.sol index 1f8edcd..dd25f88 100644 --- a/test/helpers/CoWProtocol.t.sol +++ b/test/helpers/CoWProtocol.t.sol @@ -26,15 +26,12 @@ import {Tokens} from "./Tokens.t.sol"; abstract contract CoWProtocol is Test, Tokens { using TestAccountLib for TestAccount; - // --- constants uint256 constant PAUSE_WINDOW_DURATION = 7776000; uint256 constant BUFFER_PERIOD_DURATION = 2592000; - // --- contracts IVault public vault; GPv2Settlement public settlement; - // --- accounts TestAccount admin; TestAccount solver; diff --git a/test/libraries/ComposableCoWLib.t.sol b/test/libraries/ComposableCoWLib.t.sol index afeb8f5..083547d 100644 --- a/test/libraries/ComposableCoWLib.t.sol +++ b/test/libraries/ComposableCoWLib.t.sol @@ -37,8 +37,8 @@ library ComposableCoWLib { IConditionalOrder.ConditionalOrderParams[] memory leaves, uint256 n, mapping(bytes32 => IConditionalOrder.ConditionalOrderParams) storage m, - function (bytes32[] memory) internal pure returns (bytes32) getRoot, - function (bytes32[] memory, uint256) internal pure returns (bytes32[] memory) getProof + function(bytes32[] memory) internal pure returns (bytes32) getRoot, + function(bytes32[] memory, uint256) internal pure returns (bytes32[] memory) getProof ) internal returns (bytes32, bytes32[] memory, IConditionalOrder.ConditionalOrderParams memory) { // 1. Create a mapping of hashes to leaves for (uint256 i = 0; i < leaves.length; i++) { From 2605610c22799767d65aae16531e5007dfeaae42 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 18:34:27 +0000 Subject: [PATCH 13/25] test: update mock contracts for new architecture - Add test handlers for each error type (OrderNotValid, PollTryNextBlock, etc.) - Update TestConditionalOrder to extend BaseConditionalOrder - Rename getTradeableOrder to generateOrder in mocks - Update for PollResult return type from getTradeableOrderWithSignature --- test/ComposableCoW.base.t.sol | 119 +++++++++++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 10 deletions(-) diff --git a/test/ComposableCoW.base.t.sol b/test/ComposableCoW.base.t.sol index e736f59..e86de6b 100644 --- a/test/ComposableCoW.base.t.sol +++ b/test/ComposableCoW.base.t.sol @@ -11,7 +11,7 @@ import {TestAccount, TestAccountLib} from "./libraries/TestAccountLib.t.sol"; import {SafeLib} from "./libraries/SafeLib.t.sol"; import {ComposableCoWLib} from "./libraries/ComposableCoWLib.t.sol"; -import {IConditionalOrder, IERC20, BaseConditionalOrder, INVALID_HASH} from "../src/BaseConditionalOrder.sol"; +import {IConditionalOrder, IERC20, BaseConditionalOrder} from "../src/BaseConditionalOrder.sol"; import {BaseSwapGuard} from "../src/guards/BaseSwapGuard.sol"; import {TWAP, TWAPOrder} from "../src/types/twap/TWAP.sol"; @@ -79,8 +79,6 @@ contract BaseComposableCoWTest is Base, Merkle { assertEq(composableCow.domainSeparator(), settlement.domainSeparator()); } - // --- Helpers --- - /// @dev Sets the root and checks events / state function _setRoot(address owner, bytes32 root, ComposableCoW.Proof memory proof) internal { vm.prank(owner); @@ -185,9 +183,7 @@ contract BaseComposableCoWTest is Base, Merkle { returns (IConditionalOrder.ConditionalOrderParams memory params) { params = IConditionalOrder.ConditionalOrderParams({ - handler: IConditionalOrder(handler), - salt: salt, - staticInput: staticInput + handler: IConditionalOrder(handler), salt: salt, staticInput: staticInput }); } @@ -216,9 +212,7 @@ contract BaseComposableCoWTest is Base, Merkle { _leaves = new IConditionalOrder.ConditionalOrderParams[](n); for (uint256 i = 0; i < _leaves.length; i++) { _leaves[i] = IConditionalOrder.ConditionalOrderParams({ - handler: twap, - salt: keccak256(abi.encode(bytes32(i))), - staticInput: abi.encode(twapData) + handler: twap, salt: keccak256(abi.encode(bytes32(i))), staticInput: abi.encode(twapData) }); } @@ -260,7 +254,7 @@ contract TestSwapGuard is BaseSwapGuard { /// @dev A conditional order handler used for testing that returns the GPv2Order passed in as `offchainInput` contract TestConditionalOrderGenerator is BaseConditionalOrder { - function getTradeableOrder(address, address, bytes32, bytes calldata, bytes calldata offchainInput) + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata offchainInput) public pure override @@ -277,6 +271,15 @@ contract TestNonSafeWallet is ERC1271Forwarder { /// @dev A conditional order handler used for testing that reverts on verify contract MirrorConditionalOrder is IConditionalOrder { + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + external + pure + override + returns (GPv2Order.Data memory) + { + revert OrderNotValid("mirror: not implemented"); + } + function verify( address, address, @@ -294,3 +297,99 @@ contract MirrorConditionalOrder is IConditionalOrder { } } } + +import {IConditionalOrderGenerator} from "../src/interfaces/IConditionalOrder.sol"; + +/// @dev Test handler that throws OrderNotValid error +contract OrderNotValidHandler is BaseConditionalOrder { + string public reason; + + constructor(string memory _reason) { + reason = _reason; + } + + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + public + view + override + returns (GPv2Order.Data memory) + { + revert IConditionalOrder.OrderNotValid(reason); + } +} + +/// @dev Test handler that throws PollTryNextBlock error +contract PollTryNextBlockHandler is BaseConditionalOrder { + string public reason; + + constructor(string memory _reason) { + reason = _reason; + } + + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + public + view + override + returns (GPv2Order.Data memory) + { + revert IConditionalOrder.PollTryNextBlock(reason); + } +} + +/// @dev Test handler that throws PollTryAtTimestamp error +contract PollTryAtTimestampHandler is BaseConditionalOrder { + uint256 public timestamp; + string public reason; + + constructor(uint256 _timestamp, string memory _reason) { + timestamp = _timestamp; + reason = _reason; + } + + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + public + view + override + returns (GPv2Order.Data memory) + { + revert IConditionalOrder.PollTryAtTimestamp(timestamp, reason); + } +} + +/// @dev Test handler that throws PollTryAtBlock error +contract PollTryAtBlockHandler is BaseConditionalOrder { + uint256 public blockNum; + string public reason; + + constructor(uint256 _blockNum, string memory _reason) { + blockNum = _blockNum; + reason = _reason; + } + + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + public + view + override + returns (GPv2Order.Data memory) + { + revert IConditionalOrder.PollTryAtBlock(blockNum, reason); + } +} + +/// @dev Test handler that returns a successful order +contract SuccessHandler is BaseConditionalOrder { + GPv2Order.Data public order; + + function setOrder(GPv2Order.Data memory _order) external { + order = _order; + } + + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + public + view + override + returns (GPv2Order.Data memory) + { + return order; + } +} From c513f18335d571addf871dbd0526f754fe87f665 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 18:34:36 +0000 Subject: [PATCH 14/25] test: add comprehensive poll() test coverage - Test error decoding for OrderNotValid, PollTryNextBlock, PollTryAtTimestamp, PollTryAtBlock - Test verify() hash validation - Test that getTradeableOrderWithSignature uses poll() internally - Fuzz tests for error message propagation --- test/ComposableCoW.poll.t.sol | 225 ++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 test/ComposableCoW.poll.t.sol diff --git a/test/ComposableCoW.poll.t.sol b/test/ComposableCoW.poll.t.sol new file mode 100644 index 0000000..9ef14e5 --- /dev/null +++ b/test/ComposableCoW.poll.t.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { + IConditionalOrder, + IConditionalOrderGenerator, + GPv2Order, + ComposableCoW, + BaseComposableCoWTest, + OrderNotValidHandler, + PollTryNextBlockHandler, + PollTryAtTimestampHandler, + PollTryAtBlockHandler, + SuccessHandler +} from "./ComposableCoW.base.t.sol"; + +/// @title Tests for poll() function and error decoding in BaseConditionalOrder +contract ComposableCoWPollTest is BaseComposableCoWTest { + function setUp() public virtual override(BaseComposableCoWTest) { + super.setUp(); + } + + /// @dev Test that OrderNotValid error is decoded to INVALID PollResult + function test_poll_DecodesOrderNotValid() public { + string memory expectedReason = "order is invalid"; + OrderNotValidHandler handler = new OrderNotValidHandler(expectedReason); + + IConditionalOrderGenerator.PollResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.INVALID)); + assertEq(result.reason, expectedReason); + assertEq(result.waitUntil, 0); + assertEq(result.nextPollTimestamp, 0); + } + + /// @dev Test that PollTryNextBlock error is decoded to TRY_NEXT_BLOCK PollResult + function test_poll_DecodesPollTryNextBlock() public { + string memory expectedReason = "try next block"; + PollTryNextBlockHandler handler = new PollTryNextBlockHandler(expectedReason); + + IConditionalOrderGenerator.PollResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.TRY_NEXT_BLOCK)); + assertEq(result.reason, expectedReason); + assertEq(result.waitUntil, 0); + assertEq(result.nextPollTimestamp, 0); + } + + /// @dev Test that PollTryAtTimestamp error is decoded to WAIT_TIMESTAMP PollResult + function test_poll_DecodesPollTryAtTimestamp() public { + uint256 expectedTimestamp = 1234567890; + string memory expectedReason = "wait for timestamp"; + PollTryAtTimestampHandler handler = new PollTryAtTimestampHandler(expectedTimestamp, expectedReason); + + IConditionalOrderGenerator.PollResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.WAIT_TIMESTAMP)); + assertEq(result.reason, expectedReason); + assertEq(result.waitUntil, expectedTimestamp); + assertEq(result.nextPollTimestamp, 0); + } + + /// @dev Test that PollTryAtBlock error is decoded to WAIT_BLOCK PollResult + function test_poll_DecodesPollTryAtBlock() public { + uint256 expectedBlock = 999999; + string memory expectedReason = "wait for block"; + PollTryAtBlockHandler handler = new PollTryAtBlockHandler(expectedBlock, expectedReason); + + IConditionalOrderGenerator.PollResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.WAIT_BLOCK)); + assertEq(result.reason, expectedReason); + assertEq(result.waitUntil, expectedBlock); + assertEq(result.nextPollTimestamp, 0); + } + + /// @dev Test that successful generateOrder returns SUCCESS PollResult + function test_poll_ReturnsSuccessOnValidOrder() public { + SuccessHandler handler = new SuccessHandler(); + + GPv2Order.Data memory expectedOrder = GPv2Order.Data({ + sellToken: token0, + buyToken: token1, + receiver: address(0), + sellAmount: 100e18, + buyAmount: 50e18, + validTo: uint32(block.timestamp + 1 hours), + appData: keccak256("test"), + feeAmount: 0, + kind: GPv2Order.KIND_SELL, + partiallyFillable: false, + sellTokenBalance: GPv2Order.BALANCE_ERC20, + buyTokenBalance: GPv2Order.BALANCE_ERC20 + }); + handler.setOrder(expectedOrder); + + IConditionalOrderGenerator.PollResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.SUCCESS)); + assertEq(result.reason, "order ready"); + assertEq(address(result.order.sellToken), address(expectedOrder.sellToken)); + assertEq(address(result.order.buyToken), address(expectedOrder.buyToken)); + assertEq(result.order.sellAmount, expectedOrder.sellAmount); + assertEq(result.order.buyAmount, expectedOrder.buyAmount); + } + + /// @dev Fuzz test OrderNotValid error decoding + function test_poll_FuzzOrderNotValid(string memory reason) public { + OrderNotValidHandler handler = new OrderNotValidHandler(reason); + + IConditionalOrderGenerator.PollResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.INVALID)); + assertEq(result.reason, reason); + } + + /// @dev Fuzz test PollTryAtTimestamp error decoding + function test_poll_FuzzPollTryAtTimestamp(uint256 timestamp, string memory reason) public { + PollTryAtTimestampHandler handler = new PollTryAtTimestampHandler(timestamp, reason); + + IConditionalOrderGenerator.PollResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.WAIT_TIMESTAMP)); + assertEq(result.waitUntil, timestamp); + assertEq(result.reason, reason); + } + + /// @dev Fuzz test PollTryAtBlock error decoding + function test_poll_FuzzPollTryAtBlock(uint256 blockNum, string memory reason) public { + PollTryAtBlockHandler handler = new PollTryAtBlockHandler(blockNum, reason); + + IConditionalOrderGenerator.PollResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.WAIT_BLOCK)); + assertEq(result.waitUntil, blockNum); + assertEq(result.reason, reason); + } + + /// @dev Test that getTradeableOrderWithSignature uses poll() internally and returns correct PollResult + function test_getTradeableOrderWithSignature_UsesPollInternally() public { + uint256 expectedTimestamp = block.timestamp + 1 days; + string memory expectedReason = "too early"; + PollTryAtTimestampHandler handler = new PollTryAtTimestampHandler(expectedTimestamp, expectedReason); + + IConditionalOrder.ConditionalOrderParams memory params = IConditionalOrder.ConditionalOrderParams({ + handler: IConditionalOrder(address(handler)), salt: keccak256("test"), staticInput: bytes("") + }); + + _create(address(safe1), params, false); + + (IConditionalOrderGenerator.PollResult memory result,) = + composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), new bytes32[](0)); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.WAIT_TIMESTAMP)); + assertEq(result.waitUntil, expectedTimestamp); + assertEq(result.reason, expectedReason); + } + + /// @dev Test that verify() uses generateOrder() and validates hash + function test_verify_UsesGenerateOrder() public { + SuccessHandler handler = new SuccessHandler(); + + GPv2Order.Data memory expectedOrder = GPv2Order.Data({ + sellToken: token0, + buyToken: token1, + receiver: address(0), + sellAmount: 100e18, + buyAmount: 50e18, + validTo: uint32(block.timestamp + 1 hours), + appData: keccak256("test"), + feeAmount: 0, + kind: GPv2Order.KIND_SELL, + partiallyFillable: false, + sellTokenBalance: GPv2Order.BALANCE_ERC20, + buyTokenBalance: GPv2Order.BALANCE_ERC20 + }); + handler.setOrder(expectedOrder); + + bytes32 domainSeparator = composableCow.domainSeparator(); + bytes32 orderHash = GPv2Order.hash(expectedOrder, domainSeparator); + + // Should not revert - hash matches + handler.verify( + address(safe1), address(this), orderHash, domainSeparator, bytes32(0), bytes(""), bytes(""), expectedOrder + ); + } + + /// @dev Test that verify() reverts on hash mismatch + function test_verify_RevertsOnHashMismatch() public { + SuccessHandler handler = new SuccessHandler(); + + GPv2Order.Data memory expectedOrder = GPv2Order.Data({ + sellToken: token0, + buyToken: token1, + receiver: address(0), + sellAmount: 100e18, + buyAmount: 50e18, + validTo: uint32(block.timestamp + 1 hours), + appData: keccak256("test"), + feeAmount: 0, + kind: GPv2Order.KIND_SELL, + partiallyFillable: false, + sellTokenBalance: GPv2Order.BALANCE_ERC20, + buyTokenBalance: GPv2Order.BALANCE_ERC20 + }); + handler.setOrder(expectedOrder); + + bytes32 domainSeparator = composableCow.domainSeparator(); + bytes32 wrongHash = keccak256("wrong hash"); + + // Should revert - hash doesn't match + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "invalid hash")); + handler.verify( + address(safe1), address(this), wrongHash, domainSeparator, bytes32(0), bytes(""), bytes(""), expectedOrder + ); + } +} From df8532723e6fa68f3e144f6e990ef8713395aaa2 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 18:34:58 +0000 Subject: [PATCH 15/25] test: update all order type tests for new architecture - Rename test_getTradeableOrder_* to test_generateOrder_* - Update for PollResult return type from getTradeableOrderWithSignature - Update expected error types (PollTryAtTimestamp vs OrderNotValid) - Update fuzz bounds for simulate tests - Clean up documentation comments --- test/ComposableCoW.forwarder.t.sol | 5 +- test/ComposableCoW.gat.t.sol | 89 ++++++---------- test/ComposableCoW.guards.t.sol | 28 ++--- test/ComposableCoW.stoploss.t.sol | 72 ++++--------- test/ComposableCoW.t.sol | 67 +++++------- test/ComposableCoW.tat.t.sol | 27 ++--- test/ComposableCoW.twap.t.sol | 164 ++++++++++++++++------------- 7 files changed, 193 insertions(+), 259 deletions(-) diff --git a/test/ComposableCoW.forwarder.t.sol b/test/ComposableCoW.forwarder.t.sol index fdf14ca..84b6c22 100644 --- a/test/ComposableCoW.forwarder.t.sol +++ b/test/ComposableCoW.forwarder.t.sol @@ -8,6 +8,7 @@ import { TestNonSafeWallet, ERC1271Forwarder } from "./ComposableCoW.base.t.sol"; +import {IConditionalOrderGenerator} from "../src/interfaces/IConditionalOrder.sol"; contract ComposableCoWForwarderTest is BaseComposableCoWTest { function setUp() public virtual override(BaseComposableCoWTest) { @@ -24,11 +25,11 @@ contract ComposableCoWForwarderTest is BaseComposableCoWTest { _create(address(nonSafe), params, false); // should return a valid order and signature - (GPv2Order.Data memory order, bytes memory signature) = composableCow.getTradeableOrderWithSignature( + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature( address(nonSafe), params, abi.encode(getBlankOrder()), new bytes32[](0) ); - bytes32 badDigest = GPv2Order.hash(order, keccak256("deadbeef")); + bytes32 badDigest = GPv2Order.hash(result.order, keccak256("deadbeef")); // should revert when substituting the hash with a bad one vm.expectRevert(ERC1271Forwarder.InvalidHash.selector); diff --git a/test/ComposableCoW.gat.t.sol b/test/ComposableCoW.gat.t.sol index 6aec0e8..8bf58e3 100644 --- a/test/ComposableCoW.gat.t.sol +++ b/test/ComposableCoW.gat.t.sol @@ -14,13 +14,8 @@ import { ComposableCoWLib } from "./ComposableCoW.base.t.sol"; -import { - IExpectedOutCalculator, - GoodAfterTime, - TOO_EARLY, - BALANCE_INSUFFICIENT, - PRICE_CHECKER_FAILED -} from "../src/types/GoodAfterTime.sol"; +import {IExpectedOutCalculator, GoodAfterTime} from "../src/types/GoodAfterTime.sol"; +import {IConditionalOrderGenerator} from "../src/interfaces/IConditionalOrder.sol"; contract ComposableCoWGatTest is BaseComposableCoWTest { using ComposableCoWLib for IConditionalOrder.ConditionalOrderParams[]; @@ -43,7 +38,7 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { /** * @dev Fuzz test revert on invalid start time */ - function test_getTradeableOrder_FuzzRevertBeforeStartTime(uint256 currentTime, uint256 startTime) public { + function test_generateOrder_FuzzRevertBeforeStartTime(uint256 currentTime, uint256 startTime) public { // Revert when the start time is before the current time vm.assume(currentTime < startTime); @@ -54,14 +49,14 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { vm.warp(currentTime); // should revert when the current time is before the start time - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.PollTryAtEpoch.selector, startTime, TOO_EARLY)); - gat.getTradeableOrder(address(safe1), address(0), bytes32(0), abi.encode(o), abi.encode(uint256(1e18))); + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.PollTryAtTimestamp.selector, startTime, "too early")); + gat.generateOrder(address(safe1), address(0), bytes32(0), abi.encode(o), abi.encode(uint256(1e18))); } /** * @dev Fuzz test revert on balance too low */ - function test_getTradeableOrder_FuzzRevertBelowMinBalance(uint256 currentBalance, uint256 minBalance) public { + function test_generateOrder_FuzzRevertBelowMinBalance(uint256 currentBalance, uint256 minBalance) public { // Revert when the current balance is below the minimum balance vm.assume(currentBalance < minBalance); @@ -75,31 +70,22 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { deal(address(o.sellToken), address(safe1), currentBalance); // should revert when the current balance is below the minimum balance - vm.expectRevert( - abi.encodeWithSelector( - IConditionalOrder.OrderNotValid.selector, - BALANCE_INSUFFICIENT - ) - ); - gat.getTradeableOrder(address(safe1), address(0), bytes32(0), abi.encode(o), abi.encode(uint256(1e18))); + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "balance insufficient")); + gat.generateOrder(address(safe1), address(0), bytes32(0), abi.encode(o), abi.encode(uint256(1e18))); } /** * @dev Fuzz test revert when oracle supplied buyAmount is less than the price checker */ - function test_getTradeableOrder_FuzzRevertTooLowOutput( - uint256 buyAmount, - uint256 expectedOut, - uint256 allowedSlippage - ) public { + function test_generateOrder_FuzzRevertTooLowOutput(uint256 buyAmount, uint256 expectedOut, uint256 allowedSlippage) + public + { vm.assume(expectedOut < type(uint256).max / 10000); allowedSlippage = bound(allowedSlippage, 0, 10000); vm.assume(buyAmount < expectedOut * (10000 - allowedSlippage) / 10000); GoodAfterTime.PriceCheckerPayload memory checker = GoodAfterTime.PriceCheckerPayload({ - checker: testOutCalculator, - payload: abi.encode(expectedOut), - allowedSlippage: allowedSlippage + checker: testOutCalculator, payload: abi.encode(expectedOut), allowedSlippage: allowedSlippage }); GoodAfterTime.Data memory o = _gatTest(abi.encode(checker)); @@ -110,16 +96,11 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { // set the current balance deal(address(o.sellToken), address(safe1), o.minSellBalance); - vm.expectRevert( - abi.encodeWithSelector( - IConditionalOrder.PollTryNextBlock.selector, - PRICE_CHECKER_FAILED - ) - ); - gat.getTradeableOrder(address(safe1), address(0), bytes32(0), abi.encode(o), abi.encode(buyAmount)); + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.PollTryNextBlock.selector, "price checker failed")); + gat.generateOrder(address(safe1), address(0), bytes32(0), abi.encode(o), abi.encode(buyAmount)); } - function test_getTradeableOrder_FuzzContext( + function test_generateOrder_FuzzContext( IERC20 buyToken, address owner, address receiver, @@ -154,7 +135,7 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { // This should not revert GPv2Order.Data memory order = - gat.getTradeableOrder(owner, address(0), bytes32(0), abi.encode(o), abi.encode(buyAmount)); + gat.generateOrder(owner, address(0), bytes32(0), abi.encode(o), abi.encode(buyAmount)); GPv2Order.Data memory comparison = GPv2Order.Data({ sellToken: token0, @@ -177,7 +158,7 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { ); } - function test_getTradeableOrder_e2e_Fuzz( + function test_generateOrder_e2e_Fuzz( uint256 currentTime, uint256 startTime, uint256 endTime, @@ -208,18 +189,20 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { deal(address(o.sellToken), address(safe1), currentBalance); // This should not revert - (GPv2Order.Data memory order, bytes memory signature) = composableCow.getTradeableOrderWithSignature( + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature( address(safe1), params, abi.encode(buyAmount), new bytes32[](0) ); + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.SUCCESS)); // Verify that the order is valid - this shouldn't revert assertTrue( - ERC1271(address(safe1)).isValidSignature(GPv2Order.hash(order, settlement.domainSeparator()), signature) + ERC1271(address(safe1)) + .isValidSignature(GPv2Order.hash(result.order, settlement.domainSeparator()), signature) == ERC1271.isValidSignature.selector ); } - function test_getTradeableOrder_e2e_FuzzWithPriceChecker( + function test_generateOrder_e2e_FuzzWithPriceChecker( uint256 startTime, uint256 endTime, uint256 buyAmount, @@ -236,9 +219,7 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { // Create the price checker payload GoodAfterTime.PriceCheckerPayload memory checker = GoodAfterTime.PriceCheckerPayload({ - checker: testOutCalculator, - payload: abi.encode(expectedOut), - allowedSlippage: allowedSlippage + checker: testOutCalculator, payload: abi.encode(expectedOut), allowedSlippage: allowedSlippage }); // Create the order payload @@ -256,13 +237,15 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { deal(address(o.sellToken), address(safe1), o.minSellBalance); // This should not revert - (GPv2Order.Data memory order, bytes memory signature) = composableCow.getTradeableOrderWithSignature( + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature( address(safe1), params, abi.encode(buyAmount), new bytes32[](0) ); + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.SUCCESS)); // Verify that the order is valid - this shouldn't revert assertTrue( - ERC1271(address(safe1)).isValidSignature(GPv2Order.hash(order, settlement.domainSeparator()), signature) + ERC1271(address(safe1)) + .isValidSignature(GPv2Order.hash(result.order, settlement.domainSeparator()), signature) == ERC1271.isValidSignature.selector ); } @@ -286,9 +269,7 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { // Create the price checker payload GoodAfterTime.PriceCheckerPayload memory checker = GoodAfterTime.PriceCheckerPayload({ - checker: testOutCalculator, - payload: abi.encode(expectedOut), - allowedSlippage: allowedSlippage + checker: testOutCalculator, payload: abi.encode(expectedOut), allowedSlippage: allowedSlippage }); // Create the order payload @@ -302,7 +283,7 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { deal(address(o.sellToken), address(safe1), o.minSellBalance); GPv2Order.Data memory order = - gat.getTradeableOrder(address(safe1), address(0), bytes32(0), abi.encode(o), abi.encode(buyAmount)); + gat.generateOrder(address(safe1), address(0), bytes32(0), abi.encode(o), abi.encode(buyAmount)); bytes32 domainSeparator = composableCow.domainSeparator(); // Verify that the order is valid - this shouldn't revert @@ -323,8 +304,9 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { */ function test_settle_e2e() public { // Create the price checker payload - GoodAfterTime.PriceCheckerPayload memory checker = - GoodAfterTime.PriceCheckerPayload({checker: testOutCalculator, payload: abi.encode(1), allowedSlippage: 50}); + GoodAfterTime.PriceCheckerPayload memory checker = GoodAfterTime.PriceCheckerPayload({ + checker: testOutCalculator, payload: abi.encode(1), allowedSlippage: 50 + }); // Create the order payload GoodAfterTime.Data memory o = _gatTest(abi.encode(checker)); @@ -338,16 +320,15 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { vm.warp(o.startTime); // 4. Get the order and signature - (GPv2Order.Data memory order, bytes memory signature) = composableCow.getTradeableOrderWithSignature( + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature( address(safe1), params, abi.encode(uint256(100)), new bytes32[](0) ); + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.SUCCESS)); // 5. Execute the order - settle(address(safe1), bob, order, signature, bytes4(0)); + settle(address(safe1), bob, result.order, signature, bytes4(0)); } - // --- Helper functions --- - function createOrder(Safe safe, GoodAfterTime.Data memory gatOrder, IERC20 sellToken, uint256 sellAmount) internal returns (IConditionalOrder.ConditionalOrderParams memory params) diff --git a/test/ComposableCoW.guards.t.sol b/test/ComposableCoW.guards.t.sol index d2787db..bd7ccd5 100644 --- a/test/ComposableCoW.guards.t.sol +++ b/test/ComposableCoW.guards.t.sol @@ -13,6 +13,7 @@ import { TestSwapGuard, ReceiverLock } from "./ComposableCoW.base.t.sol"; +import {IConditionalOrderGenerator} from "../src/interfaces/IConditionalOrder.sol"; contract ComposableCoWGuardsTest is BaseComposableCoWTest { function setUp() public virtual override(BaseComposableCoWTest) { @@ -63,9 +64,9 @@ contract ComposableCoWGuardsTest is BaseComposableCoWTest { uint256 snapshot = vm.snapshot(); // should work as there is no swap guard set - (GPv2Order.Data memory order, bytes memory signature) = + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), proof); - settle(address(safe1), bob, order, signature, bytes4(0)); + settle(address(safe1), bob, result.order, signature, bytes4(0)); // restores the state vm.revertTo(snapshot); @@ -74,17 +75,18 @@ contract ComposableCoWGuardsTest is BaseComposableCoWTest { _setSwapGuard(address(safe1), evenSwapGuard); // should not be able to settle as the swap guard doesn't allow it - settle(address(safe1), bob, order, signature, ComposableCoW.SwapGuardRestricted.selector); + settle(address(safe1), bob, result.order, signature, ComposableCoW.SwapGuardRestricted.selector); - // should not be able to return the order as the swap guard doesn't allow it - vm.expectRevert(ComposableCoW.SwapGuardRestricted.selector); - composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), proof); + // should return INVALID as the swap guard doesn't allow it + (IConditionalOrderGenerator.PollResult memory guardResult,) = + composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), proof); + assertEq(uint256(guardResult.code), uint256(IConditionalOrderGenerator.PollResultCode.INVALID)); // should set the swap guard to the odd swap guard _setSwapGuard(address(safe1), oddSwapGuard); // should be able to settle as the swap guard allows it - settle(address(safe1), bob, order, signature, bytes4(0)); + settle(address(safe1), bob, result.order, signature, bytes4(0)); // can remove the swap guard _setSwapGuard(address(safe1), ISwapGuard(address(0))); @@ -117,21 +119,21 @@ contract ComposableCoWGuardsTest is BaseComposableCoWTest { bytes32 domainSeparator = composableCow.domainSeparator(); // should return a valid order and signature (no guard is set) - (GPv2Order.Data memory order, bytes memory signature) = composableCow.getTradeableOrderWithSignature( + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature( address(safe1), params, abi.encode(orderOtherReceiver), new bytes32[](0) ); // should set the swap guard _setSwapGuard(address(safe1), lock); - // should revert as the receiver is not the safe - vm.expectRevert(ComposableCoW.SwapGuardRestricted.selector); - composableCow.getTradeableOrderWithSignature( + // should return INVALID as the receiver is not the safe (polling path doesn't revert) + (IConditionalOrderGenerator.PollResult memory guardResult,) = composableCow.getTradeableOrderWithSignature( address(safe1), params, abi.encode(orderOtherReceiver), new bytes32[](0) ); + assertEq(uint256(guardResult.code), uint256(IConditionalOrderGenerator.PollResultCode.INVALID)); - // should revert as the receiver is not the safe + // should revert as the receiver is not the safe (settlement path still reverts) vm.expectRevert(ComposableCoW.SwapGuardRestricted.selector); - ERC1271(address(safe1)).isValidSignature(GPv2Order.hash(order, domainSeparator), signature); + ERC1271(address(safe1)).isValidSignature(GPv2Order.hash(result.order, domainSeparator), signature); } } diff --git a/test/ComposableCoW.stoploss.t.sol b/test/ComposableCoW.stoploss.t.sol index f2fb784..22e1fa3 100644 --- a/test/ComposableCoW.stoploss.t.sol +++ b/test/ComposableCoW.stoploss.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.0 <0.9.0; import {IERC20, GPv2Order, IConditionalOrder, BaseComposableCoWTest} from "./ComposableCoW.base.t.sol"; import {IAggregatorV3Interface} from "../src/interfaces/IAggregatorV3Interface.sol"; -import {StopLoss, STRIKE_NOT_REACHED, ORACLE_STALE_PRICE, ORACLE_INVALID_PRICE, ORDER_EXPIRED} from "../src/types/StopLoss.sol"; +import {StopLoss} from "../src/types/StopLoss.sol"; contract ComposableCoWStopLossTest is BaseComposableCoWTest { IERC20 immutable SELL_TOKEN = IERC20(address(0x1)); @@ -63,13 +63,8 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { createOrder(stopLoss, 0x0, abi.encode(data)); - vm.expectRevert( - abi.encodeWithSelector( - IConditionalOrder.PollTryNextBlock.selector, - STRIKE_NOT_REACHED - ) - ); - stopLoss.getTradeableOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.PollTryNextBlock.selector, "strike not reached")); + stopLoss.generateOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); } function test_RevertStrikePriceNotMet_fuzz( @@ -106,13 +101,8 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { maxTimeSinceLastOracleUpdate: staleTime }); - vm.expectRevert( - abi.encodeWithSelector( - IConditionalOrder.PollTryNextBlock.selector, - STRIKE_NOT_REACHED - ) - ); - stopLoss.getTradeableOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.PollTryNextBlock.selector, "strike not reached")); + stopLoss.generateOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); } function test_OracleNormalisesPrice_fuzz( @@ -144,11 +134,9 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { ), strike: int256( 1900 - * ( - sellTokenERC20Decimals > buyTokenERC20Decimals + * (sellTokenERC20Decimals > buyTokenERC20Decimals ? (10 ** (sellTokenERC20Decimals - buyTokenERC20Decimals + 18)) - : (10 ** (buyTokenERC20Decimals - sellTokenERC20Decimals + 18)) - ) + : (10 ** (buyTokenERC20Decimals - sellTokenERC20Decimals + 18))) ), // Strike price is to 18 decimals, base / quote. ie. 1900_000_000_000_000_000_000 = 1900 USDC/ETH sellAmount: 1 ether, buyAmount: 1, @@ -160,8 +148,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { maxTimeSinceLastOracleUpdate: 15 minutes }); - GPv2Order.Data memory order = - stopLoss.getTradeableOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); + GPv2Order.Data memory order = stopLoss.generateOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); assertEq(address(order.sellToken), address(SELL_TOKEN)); assertEq(address(order.buyToken), address(BUY_TOKEN)); assertEq(order.sellAmount, 1 ether); @@ -196,8 +183,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { maxTimeSinceLastOracleUpdate: 15 minutes }); - GPv2Order.Data memory order = - stopLoss.getTradeableOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); + GPv2Order.Data memory order = stopLoss.generateOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); assertEq(address(order.sellToken), address(SELL_TOKEN)); assertEq(address(order.buyToken), address(BUY_TOKEN)); assertEq(order.sellAmount, 1 ether); @@ -241,13 +227,8 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { maxTimeSinceLastOracleUpdate: maxTimeSinceLastOracleUpdate }); - vm.expectRevert( - abi.encodeWithSelector( - IConditionalOrder.PollTryNextBlock.selector, - ORACLE_STALE_PRICE - ) - ); - stopLoss.getTradeableOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.PollTryNextBlock.selector, "oracle stale price")); + stopLoss.generateOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); } function test_OracleRevertOnInvalidPrice_fuzz(int256 invalidPrice, int256 validPrice) public { @@ -275,22 +256,19 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { maxTimeSinceLastOracleUpdate: 15 minutes }); - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, ORACLE_INVALID_PRICE)); - stopLoss.getTradeableOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "oracle invalid price")); + stopLoss.generateOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); // case where buy token price is invalid data.sellTokenPriceOracle = mockOracle(SELL_ORACLE, validPrice, block.timestamp, DEFAULT_DECIMALS); data.buyTokenPriceOracle = mockOracle(BUY_ORACLE, invalidPrice, block.timestamp, DEFAULT_DECIMALS); - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, ORACLE_INVALID_PRICE)); - stopLoss.getTradeableOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "oracle invalid price")); + stopLoss.generateOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); } - function test_OracleRevertOnExpiredOrder_fuzz( - uint32 currentTime, - uint32 validTo - ) public { + function test_OracleRevertOnExpiredOrder_fuzz(uint32 currentTime, uint32 validTo) public { // enforce expired order vm.assume(currentTime > validTo); @@ -312,19 +290,8 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { maxTimeSinceLastOracleUpdate: 15 minutes }); - vm.expectRevert( - abi.encodeWithSelector( - IConditionalOrder.OrderNotValid.selector, - ORDER_EXPIRED - ) - ); - stopLoss.getTradeableOrder( - safe, - address(0), - bytes32(0), - abi.encode(data), - bytes("") - ); + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "order expired")); + stopLoss.generateOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); } function test_strikePriceMet_fuzz( @@ -358,8 +325,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { maxTimeSinceLastOracleUpdate: 15 minutes }); - GPv2Order.Data memory order = - stopLoss.getTradeableOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); + GPv2Order.Data memory order = stopLoss.generateOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); assertEq(address(order.sellToken), address(SELL_TOKEN)); assertEq(address(order.buyToken), address(BUY_TOKEN)); assertEq(order.sellAmount, 1 ether); diff --git a/test/ComposableCoW.t.sol b/test/ComposableCoW.t.sol index f1c3ec4..c877d4e 100644 --- a/test/ComposableCoW.t.sol +++ b/test/ComposableCoW.t.sol @@ -8,11 +8,11 @@ import { GPv2Order, ComposableCoW, ComposableCoWLib, - INVALID_HASH, BaseComposableCoWTest, Safe, TestNonSafeWallet } from "./ComposableCoW.base.t.sol"; +import {IConditionalOrderGenerator} from "../src/interfaces/IConditionalOrder.sol"; contract ComposableCoWTest is BaseComposableCoWTest { using ComposableCoWLib for IConditionalOrder.ConditionalOrderParams[]; @@ -55,14 +55,14 @@ contract ComposableCoWTest is BaseComposableCoWTest { _setRoot(address(safe1), root, proofStruct); // should pass with the root correctly set - (GPv2Order.Data memory order, bytes memory signature) = + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), proof); // save the state uint256 snapshot = vm.snapshot(); // should successfully execute the order - settle(address(safe1), bob, order, signature, bytes4(0)); + settle(address(safe1), bob, result.order, signature, bytes4(0)); // restore the state vm.revertTo(snapshot); @@ -97,14 +97,14 @@ contract ComposableCoWTest is BaseComposableCoWTest { _setRootWithContext(address(safe1), root, proofStruct, testContextValue, abi.encode(bytes32("testValue"))); // should pass with the root correctly set - (GPv2Order.Data memory order, bytes memory signature) = + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), proof); // save the state uint256 snapshot = vm.snapshot(); // should successfully execute the order - settle(address(safe1), bob, order, signature, bytes4(0)); + settle(address(safe1), bob, result.order, signature, bytes4(0)); // restore the state vm.revertTo(snapshot); @@ -120,9 +120,7 @@ contract ComposableCoWTest is BaseComposableCoWTest { /// @dev Should disallow setting a handler that is address(0) function test_create_RevertOnInvalidHandler() public { IConditionalOrder.ConditionalOrderParams memory params = IConditionalOrder.ConditionalOrderParams({ - handler: IConditionalOrder(address(0)), - salt: keccak256("zero is invalid handler"), - staticInput: "" + handler: IConditionalOrder(address(0)), salt: keccak256("zero is invalid handler"), staticInput: "" }); vm.expectRevert(ComposableCoW.InvalidHandler.selector); @@ -139,9 +137,7 @@ contract ComposableCoWTest is BaseComposableCoWTest { vm.assume(handler != address(0)); IConditionalOrder.ConditionalOrderParams memory params = IConditionalOrder.ConditionalOrderParams({ - handler: IConditionalOrder(handler), - salt: salt, - staticInput: staticInput + handler: IConditionalOrder(handler), salt: salt, staticInput: staticInput }); bytes32 orderHash = keccak256(abi.encode(params)); @@ -167,9 +163,7 @@ contract ComposableCoWTest is BaseComposableCoWTest { vm.assume(handler != address(0)); IConditionalOrder.ConditionalOrderParams memory params = IConditionalOrder.ConditionalOrderParams({ - handler: IConditionalOrder(handler), - salt: salt, - staticInput: staticInput + handler: IConditionalOrder(handler), salt: salt, staticInput: staticInput }); bytes32 orderHash = keccak256(abi.encode(params)); @@ -214,11 +208,11 @@ contract ComposableCoWTest is BaseComposableCoWTest { uint256 snapshot = vm.snapshot(); // order can be returned as it is authorized - (GPv2Order.Data memory order, bytes memory signature) = + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), proof); // should successfully settle the order - settle(address(safe1), bob, order, signature, bytes4(0)); + settle(address(safe1), bob, result.order, signature, bytes4(0)); // restores the state vm.revertTo(snapshot); @@ -227,7 +221,7 @@ contract ComposableCoWTest is BaseComposableCoWTest { _remove(address(safe1), params); // should fail to settle the order as it has been removed - settle(address(safe1), bob, order, signature, ComposableCoW.SingleOrderNotAuthed.selector); + settle(address(safe1), bob, result.order, signature, ComposableCoW.SingleOrderNotAuthed.selector); } /// @dev `BaseConditionalOrder` enforces that the order hash is valid @@ -246,7 +240,7 @@ contract ComposableCoWTest is BaseComposableCoWTest { bytes32 domainSeparator = composableCow.domainSeparator(); // should revert as the order hash mismatches - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, INVALID_HASH)); + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "invalid hash")); composableCow.isValidSafeSignature( Safe(payable(address(alice.addr))), address(0), @@ -255,7 +249,9 @@ contract ComposableCoWTest is BaseComposableCoWTest { bytes32(0), abi.encode(order1), abi.encode( - ComposableCoW.PayloadStruct({proof: new bytes32[](0), params: params, offchainInput: abi.encode(order2)}) + ComposableCoW.PayloadStruct({ + proof: new bytes32[](0), params: params, offchainInput: abi.encode(order2) + }) ) ); } @@ -273,9 +269,7 @@ contract ComposableCoWTest is BaseComposableCoWTest { vm.assume(proof.length > 0); IConditionalOrder.ConditionalOrderParams memory params = IConditionalOrder.ConditionalOrderParams({ - handler: IConditionalOrder(handler), - salt: salt, - staticInput: staticInput + handler: IConditionalOrder(handler), salt: salt, staticInput: staticInput }); // should set the root @@ -304,9 +298,7 @@ contract ComposableCoWTest is BaseComposableCoWTest { vm.assume(handler != address(0)); IConditionalOrder.ConditionalOrderParams memory params = IConditionalOrder.ConditionalOrderParams({ - handler: IConditionalOrder(handler), - salt: salt, - staticInput: staticInput + handler: IConditionalOrder(handler), salt: salt, staticInput: staticInput }); // should revert as the order has not been created @@ -327,9 +319,7 @@ contract ComposableCoWTest is BaseComposableCoWTest { // Use the mirror handler as we can use it to inspect the calldata // passed to the handler. IConditionalOrder.ConditionalOrderParams memory params = IConditionalOrder.ConditionalOrderParams({ - handler: IConditionalOrder(mirror), - salt: keccak256("mirror"), - staticInput: bytes("") + handler: IConditionalOrder(mirror), salt: keccak256("mirror"), staticInput: bytes("") }); // should create a single order @@ -381,9 +371,7 @@ contract ComposableCoWTest is BaseComposableCoWTest { function test_getTradeableOrderWithSignature_RevertInterfaceNotSupported() public { // use the mirror handler as it does not support the interface IConditionalOrder.ConditionalOrderParams memory params = IConditionalOrder.ConditionalOrderParams({ - handler: mirror, - salt: keccak256("mirror"), - staticInput: bytes("") + handler: mirror, salt: keccak256("mirror"), staticInput: bytes("") }); // should create a single order @@ -407,9 +395,7 @@ contract ComposableCoWTest is BaseComposableCoWTest { vm.assume(proof.length > 0); IConditionalOrder.ConditionalOrderParams memory params = IConditionalOrder.ConditionalOrderParams({ - handler: IConditionalOrder(handler), - salt: salt, - staticInput: staticInput + handler: IConditionalOrder(handler), salt: salt, staticInput: staticInput }); // should set the root @@ -428,9 +414,7 @@ contract ComposableCoWTest is BaseComposableCoWTest { bytes memory staticInput ) public { IConditionalOrder.ConditionalOrderParams memory params = IConditionalOrder.ConditionalOrderParams({ - handler: IConditionalOrder(handler), - salt: salt, - staticInput: staticInput + handler: IConditionalOrder(handler), salt: salt, staticInput: staticInput }); // should revert as the order has not been created @@ -447,13 +431,14 @@ contract ComposableCoWTest is BaseComposableCoWTest { _create(address(safe1), params, false); // should return a valid order and signature - (GPv2Order.Data memory order, bytes memory signature) = composableCow.getTradeableOrderWithSignature( + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature( address(safe1), params, abi.encode(getBlankOrder()), new bytes32[](0) ); // order should be valid by using the `isValidSignature` function on the safe assertEq( - ERC1271(address(safe1)).isValidSignature(GPv2Order.hash(order, composableCow.domainSeparator()), signature), + ERC1271(address(safe1)) + .isValidSignature(GPv2Order.hash(result.order, composableCow.domainSeparator()), signature), ERC1271.isValidSignature.selector ); } @@ -468,13 +453,13 @@ contract ComposableCoWTest is BaseComposableCoWTest { _create(address(nonSafe), params, false); // should return a valid order and signature - (GPv2Order.Data memory order, bytes memory signature) = composableCow.getTradeableOrderWithSignature( + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature( address(nonSafe), params, abi.encode(getBlankOrder()), new bytes32[](0) ); // order should be valid by using the `isValidSignature` function on the non-safe assertEq( - nonSafe.isValidSignature(GPv2Order.hash(order, composableCow.domainSeparator()), signature), + nonSafe.isValidSignature(GPv2Order.hash(result.order, composableCow.domainSeparator()), signature), ERC1271.isValidSignature.selector ); } diff --git a/test/ComposableCoW.tat.t.sol b/test/ComposableCoW.tat.t.sol index 650f6fd..5965343 100644 --- a/test/ComposableCoW.tat.t.sol +++ b/test/ComposableCoW.tat.t.sol @@ -5,7 +5,8 @@ import {ERC1271} from "safe/handler/extensible/SignatureVerifierMuxer.sol"; import "./ComposableCoW.base.t.sol"; -import "../src/types/TradeAboveThreshold.sol"; +import {TradeAboveThreshold} from "../src/types/TradeAboveThreshold.sol"; +import {BALANCE_INSUFFICIENT} from "../src/types/GoodAfterTime.sol"; import {ConditionalOrdersUtilsLib as Utils} from "../src/types/ConditionalOrdersUtilsLib.sol"; contract ComposableCoWTatTest is BaseComposableCoWTest { @@ -24,7 +25,7 @@ contract ComposableCoWTatTest is BaseComposableCoWTest { /** * @dev Fuzz test revert on balance too low */ - function test_getTradeableOrder_FuzzRevertBelowThreshold(uint256 currentBalance, uint256 threshold) public { + function test_generateOrder_FuzzRevertBelowThreshold(uint256 currentBalance, uint256 threshold) public { // Revert when the current balance is below the minimum balance vm.assume(currentBalance < threshold); @@ -35,21 +36,11 @@ contract ComposableCoWTatTest is BaseComposableCoWTest { deal(address(o.sellToken), address(safe1), currentBalance); // should revert when the current balance is below the minimum balance - vm.expectRevert( - abi.encodeWithSelector( - IConditionalOrder.PollTryNextBlock.selector, - BALANCE_INSUFFICIENT - ) - ); - tat.getTradeableOrder(address(safe1), address(0), bytes32(0), abi.encode(o), bytes("")); + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.PollTryNextBlock.selector, BALANCE_INSUFFICIENT)); + tat.generateOrder(address(safe1), address(0), bytes32(0), abi.encode(o), bytes("")); } - function test_BalanceMet_fuzz( - address receiver, - uint256 threshold, - bytes32 appData, - uint256 currentBalance - ) public { + function test_BalanceMet_fuzz(address receiver, uint256 threshold, bytes32 appData, uint256 currentBalance) public { vm.assume(threshold > 0); vm.assume(currentBalance >= threshold); @@ -65,14 +56,12 @@ contract ComposableCoWTatTest is BaseComposableCoWTest { appData: appData }); - // // set the current balance deal(address(token0), address(safe1), currentBalance); // This should not revert GPv2Order.Data memory order = - tat.getTradeableOrder(address(safe1), address(0), bytes32(0), abi.encode(data), bytes("")); - + tat.generateOrder(address(safe1), address(0), bytes32(0), abi.encode(data), bytes("")); assertEq(address(order.sellToken), address(token0)); assertEq(address(order.buyToken), address(token1)); @@ -88,8 +77,6 @@ contract ComposableCoWTatTest is BaseComposableCoWTest { assertEq(order.buyTokenBalance, GPv2Order.BALANCE_ERC20); } - // --- Helper functions --- - function _tatTest() internal view returns (TradeAboveThreshold.Data memory) { return TradeAboveThreshold.Data({ sellToken: token0, diff --git a/test/ComposableCoW.twap.t.sol b/test/ComposableCoW.twap.t.sol index 09c47ee..80c1cf5 100644 --- a/test/ComposableCoW.twap.t.sol +++ b/test/ComposableCoW.twap.t.sol @@ -28,9 +28,8 @@ import { INVALID_FREQUENCY, INVALID_SPAN } from "../src/types/twap/libraries/TWAPOrder.sol"; -import { - TWAPOrderMathLib, BEFORE_TWAP_START, AFTER_TWAP_FINISH -} from "../src/types/twap/libraries/TWAPOrderMathLib.sol"; +import {TWAPOrderMathLib, BEFORE_TWAP_START, AFTER_TWAP_FINISH} from "../src/types/twap/libraries/TWAPOrderMathLib.sol"; +import {IConditionalOrderGenerator} from "../src/interfaces/IConditionalOrder.sol"; import {CurrentBlockTimestampFactory} from "../src/value_factories/CurrentBlockTimestampFactory.sol"; @@ -71,100 +70,100 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { /** * @dev Revert when the sell token and buy token are the same */ - function test_getTradeableOrder_RevertOnSameTokens() public { + function test_generateOrder_RevertOnSameTokens() public { // Revert when the same token is used for both the buy and sell token TWAPOrder.Data memory o = _twapTestBundle(block.timestamp); o.sellToken = token0; o.buyToken = token0; vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, INVALID_SAME_TOKEN)); - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } /** * @dev Revert when either the buy or sell token is address(0) */ - function test_getTradeableOrder_RevertOnTokenZero() public { + function test_generateOrder_RevertOnTokenZero() public { // Revert when either the buy or sell token is address(0) TWAPOrder.Data memory o = _twapTestBundle(block.timestamp); o.sellToken = IERC20(address(0)); vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, INVALID_TOKEN)); - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); o.sellToken = token0; o.buyToken = IERC20(address(0)); vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, INVALID_TOKEN)); - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } /** * @dev Revert when the sell amount is 0 */ - function test_getTradeableOrder_RevertOnZeroPartSellAmount() public { + function test_generateOrder_RevertOnZeroPartSellAmount() public { // Revert when the sell amount is zero TWAPOrder.Data memory o = _twapTestBundle(block.timestamp); o.partSellAmount = 0; vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, INVALID_PART_SELL_AMOUNT)); - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } /** * @dev Revert when the min part limit is 0 */ - function test_getTradeableOrder_RevertOnZeroMinPartLimit() public { + function test_generateOrder_RevertOnZeroMinPartLimit() public { // Revert when the limit is zero TWAPOrder.Data memory o = _twapTestBundle(block.timestamp); o.minPartLimit = 0; vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, INVALID_MIN_PART_LIMIT)); - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } /** * @dev Fuzz test revert on invalid start time */ - function test_getTradeableOrder_FuzzRevertOnInvalidStartTime(uint256 startTime) public { + function test_generateOrder_FuzzRevertOnInvalidStartTime(uint256 startTime) public { vm.assume(startTime >= type(uint32).max); // Revert when the start time exceeds or equals the max uint32 TWAPOrder.Data memory o = _twapTestBundle(startTime); o.t0 = startTime; vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, INVALID_START_TIME)); - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } /** * @dev Fuzz test revert on invalid numParts */ - function test_getTradeableOrder_FuzzRevertOnInvalidNumParts(uint256 numParts) public { + function test_generateOrder_FuzzRevertOnInvalidNumParts(uint256 numParts) public { vm.assume(numParts < 2 || numParts > type(uint32).max); // Revert if not an actual TWAP (ie. numParts < 2) TWAPOrder.Data memory o = _twapTestBundle(block.timestamp); o.n = numParts; vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, INVALID_NUM_PARTS)); - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } /** * @dev Fuzz test revert on invalid frequency */ - function test_getTradeableOrder_FuzzRevertOnInvalidFrequency(uint256 frequency) public { + function test_generateOrder_FuzzRevertOnInvalidFrequency(uint256 frequency) public { vm.assume(frequency < 1 || frequency > 365 days); TWAPOrder.Data memory o = _twapTestBundle(block.timestamp); o.t = frequency; vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, INVALID_FREQUENCY)); - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } /** * @dev Fuzz test revert on invalid span */ - function test_getTradeableOrder_FuzzRevertOnInvalidSpan(uint256 frequency, uint256 span) public { + function test_generateOrder_FuzzRevertOnInvalidSpan(uint256 frequency, uint256 span) public { vm.assume(frequency > 0 && frequency <= 365 days); vm.assume(span > frequency); @@ -173,13 +172,13 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { o.span = span; vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, INVALID_SPAN)); - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } /** * @dev Fuzz test to make sure that the order reverts if the current time is before the start time */ - function test_getTradeableOrder_FuzzRevertIfBeforeStart(uint256 startTime, uint256 currentTime) public { + function test_generateOrder_FuzzRevertIfBeforeStart(uint256 startTime, uint256 currentTime) public { // guard against overflows vm.assume(startTime < (type(uint32).max - FREQUENCY)); // force revert before start @@ -192,19 +191,21 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { vm.warp(startTime); // Verify that the order is valid - this shouldn't revert - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); // Warp to current time vm.warp(currentTime); - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, BEFORE_TWAP_START)); - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + vm.expectRevert( + abi.encodeWithSelector(IConditionalOrder.PollTryAtTimestamp.selector, startTime, BEFORE_TWAP_START) + ); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } /** * @dev Fuzz test that the order reverts if the current time is after the expiry */ - function test_getTradeableOrder_FuzzRevertIfExpired(uint256 startTime, uint256 currentTime) public { + function test_generateOrder_FuzzRevertIfExpired(uint256 startTime, uint256 currentTime) public { // guard against overflows vm.assume(startTime <= type(uint32).max); vm.assume(currentTime <= type(uint32).max); @@ -218,19 +219,19 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { vm.warp(startTime); // Verify that the order is valid - this shouldn't revert - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); // Warp to expiry vm.warp(currentTime); vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, AFTER_TWAP_FINISH)); - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } /** * @dev Fuzz test that the order reverts if the current time is outside of the span */ - function test_getTradeableOrder_FuzzRevertIfOutsideSpan(uint256 startTime, uint256 currentTime) public { + function test_generateOrder_FuzzRevertIfOutsideSpan(uint256 startTime, uint256 currentTime) public { // guard against overflows vm.assume(startTime < type(uint32).max); vm.assume(currentTime < type(uint32).max); @@ -240,25 +241,36 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { vm.assume(currentTime < startTime + (FREQUENCY * NUM_PARTS)); // guard against no reversion when within span vm.assume((currentTime - startTime) % FREQUENCY >= SPAN); + + // Calculate part and next part start for additional guard + uint256 part = (currentTime - startTime) / FREQUENCY; + uint256 nextPartStart = startTime + ((part + 1) * FREQUENCY); + uint256 endTime = startTime + (FREQUENCY * NUM_PARTS); + + // Guard against the edge case where we're in the last part's post-span period + // In this case, nextPartStart >= endTime so TWAP is finished (OrderNotValid not PollTryAtTimestamp) + vm.assume(nextPartStart < endTime); + // Revert when the order is signed by the safe and cancelled TWAPOrder.Data memory o = _twapTestBundle(startTime); vm.warp(startTime); // Verify that the order is valid - this shouldn't revert - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); // Warp to outside of the span vm.warp(currentTime); - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, NOT_WITHIN_SPAN)); - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + vm.expectRevert( + abi.encodeWithSelector(IConditionalOrder.PollTryAtTimestamp.selector, nextPartStart, NOT_WITHIN_SPAN) + ); + twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } - function test_getTradeableOrder_FuzzRevertIfOrderBeforeBlockTimestamp( - uint256 ctxBlockTimestamp, - uint256 currentTime - ) public { + function test_generateOrder_FuzzRevertIfOrderBeforeBlockTimestamp(uint256 ctxBlockTimestamp, uint256 currentTime) + public + { // guard against overflows vm.assume(ctxBlockTimestamp < type(uint32).max); vm.assume(currentTime < type(uint32).max); @@ -279,12 +291,14 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { // Warp to the current time vm.warp(currentTime); - // The below should revert - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, BEFORE_TWAP_START)); - composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), new bytes32[](0)); + // Should return WAIT_TIMESTAMP since we're before the start time + (IConditionalOrderGenerator.PollResult memory result,) = + composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), new bytes32[](0)); + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.WAIT_TIMESTAMP)); + assertEq(result.waitUntil, ctxBlockTimestamp); } - function test_getTradeableOrder_FuzzRevertIfOrderAfterBlocktimestampValidity( + function test_generateOrder_FuzzRevertIfOrderAfterBlocktimestampValidity( uint256 ctxBlockTimestamp, uint256 currentTime ) public { @@ -308,12 +322,13 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { // Warp to the current time vm.warp(currentTime); - // The below should revert - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, AFTER_TWAP_FINISH)); - composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), new bytes32[](0)); + // Should return INVALID since the TWAP has finished (permanent failure) + (IConditionalOrderGenerator.PollResult memory result,) = + composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), new bytes32[](0)); + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.PollResultCode.INVALID)); } - function test_getTradeableOrder_e2e_fuzz_WithContext(uint32 _blockTimestamp, uint256 currentTime) public { + function test_generateOrder_e2e_fuzz_WithContext(uint32 _blockTimestamp, uint256 currentTime) public { // guard against overflows vm.assume(_blockTimestamp < type(uint32).max); vm.assume(currentTime < type(uint32).max); @@ -337,12 +352,13 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { assertEq(composableCow.cabinet(address(safe1), composableCow.hash(params)), bytes32(uint256(_blockTimestamp))); // This should not revert - (GPv2Order.Data memory part, bytes memory signature) = + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), new bytes32[](0)); // Verify that the order is valid - this shouldn't revert assertTrue( - ERC1271(address(safe1)).isValidSignature(GPv2Order.hash(part, settlement.domainSeparator()), signature) + ERC1271(address(safe1)) + .isValidSignature(GPv2Order.hash(result.order, settlement.domainSeparator()), signature) == ERC1271.isValidSignature.selector ); @@ -356,7 +372,7 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { /** * @dev Fuzz test an order that is valid and should not revert from e2e */ - function test_getTradeableOrder_e2e_fuzz(uint256 startTime, uint256 currentTime) public { + function test_generateOrder_e2e_fuzz(uint256 startTime, uint256 currentTime) public { // guard against overflows vm.assume(startTime < type(uint32).max); vm.assume(currentTime < type(uint32).max); @@ -377,12 +393,13 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { vm.warp(currentTime); // This should not revert - (GPv2Order.Data memory part, bytes memory signature) = + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), new bytes32[](0)); // Verify that the order is valid - this shouldn't revert assertTrue( - ERC1271(address(safe1)).isValidSignature(GPv2Order.hash(part, settlement.domainSeparator()), signature) + ERC1271(address(safe1)) + .isValidSignature(GPv2Order.hash(result.order, settlement.domainSeparator()), signature) == ERC1271.isValidSignature.selector ); } @@ -406,8 +423,7 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { // Warp to the current time vm.warp(currentTime); - GPv2Order.Data memory order = - twap.getTradeableOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); + GPv2Order.Data memory order = twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); bytes32 domainSeparator = composableCow.domainSeparator(); // Verify that the order is valid - this shouldn't revert @@ -438,11 +454,11 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { _setRoot(address(safe1), root, ComposableCoW.Proof({location: 0, data: ""})); // 4. Get the order and signature - (GPv2Order.Data memory order, bytes memory signature) = + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = composableCow.getTradeableOrderWithSignature(address(safe1), leaf, bytes(""), proof); // 5. Execute the order - settle(address(safe1), bob, order, signature, bytes4(0)); + settle(address(safe1), bob, result.order, signature, bytes4(0)); } /** @@ -459,12 +475,9 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { // guard against reversions numParts = uint32(bound(numParts, 2, type(uint32).max)); frequency = uint32(bound(frequency, 120, type(uint32).max)); - // provide some sane limits to avoid out of gas on test issues - vm.assume( - span == 0 - ? uint256(numParts) * uint256(frequency) < 1 hours - : uint256(numParts) * uint256(span) + (uint256(numParts) * uint256(frequency - span) * 3) < 4 hours - ); + // provide conservative limits to avoid memory OOG during testing + // (the loop iterates once per second from t0 to t0 + numParts*frequency) + vm.assume(uint256(numParts) * uint256(frequency) < 25 minutes); // Assemble the TWAP bundle TWAPOrder.Data memory bundle = _twapTestBundle(block.timestamp); @@ -497,17 +510,18 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { while (true) { // Simulate being called by the watch tower + (IConditionalOrderGenerator.PollResult memory result, bytes memory signature) = + composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), new bytes32[](0)); - try composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), new bytes32[](0)) - returns (GPv2Order.Data memory order, bytes memory signature) { - bytes32 orderDigest = GPv2Order.hash(order, settlement.domainSeparator()); + if (result.code == IConditionalOrderGenerator.PollResultCode.SUCCESS) { + bytes32 orderDigest = GPv2Order.hash(result.order, settlement.domainSeparator()); if ( orderFills[orderDigest] == 0 && ERC1271(address(safe1)).isValidSignature(orderDigest, signature) == ERC1271.isValidSignature.selector ) { // Have a new order, so let's settle it - settle(address(safe1), bob, order, signature, bytes4(0)); + settle(address(safe1), bob, result.order, signature, bytes4(0)); orderFills[orderDigest] = 1; totalFills++; @@ -515,20 +529,20 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { // only count this second if we didn't revert numSecsProcessed++; - } catch (bytes memory lowLevelData) { - bytes4 receivedSelector = bytes4(lowLevelData); - - // Should have reverted if the `numSecsProcessed` > `frequency * numParts` - if (block.timestamp == endTime && receivedSelector == IConditionalOrder.OrderNotValid.selector) { + } else if (result.code == IConditionalOrderGenerator.PollResultCode.INVALID) { + // Should have returned INVALID if the `numSecsProcessed` > `frequency * numParts` + if (block.timestamp == endTime) { break; } else if (block.timestamp > endTime) { - revert("OrderNotValid() should have been thrown"); + revert("INVALID should have been returned"); } // The order should always be valid because there is no span - if (span == 0 && receivedSelector == IConditionalOrder.OrderNotValid.selector) { - revert("OrderNotValid() should not be thrown"); + if (span == 0) { + revert("INVALID should not be returned"); } + } else if (result.code == IConditionalOrderGenerator.PollResultCode.WAIT_TIMESTAMP) { + // Outside span, wait for next part } vm.warp(block.timestamp + 1 seconds); } @@ -579,12 +593,12 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { uint256 frequency, uint256 span ) public { - // --- Implicit assumptions + // Implicit assumptions: // `currentTime` is always set to the `block.timestamp` in the TWAP order, so we can assume that it is less // than the max uint32 value. vm.assume(currentTime <= type(uint32).max); - // --- Assertions + // Assertions: // number of parts is asserted to be less than the max uint32 value in the TWAP order, so we can assume that // it is less than the max uint32 value. numParts = bound(numParts, 2, type(uint32).max); @@ -597,7 +611,7 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { // equal to the frequency. vm.assume(span <= frequency); - // --- In-function revert conditions + // In-function revert conditions: // We only calculate `validTo` if we are within the TWAP order's time window, so we can assume that the current // time is greater than or equal to the start time. vm.assume(currentTime >= startTime); @@ -612,7 +626,7 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { // than the end time of the current part. vm.assume(currentTime < startTime + ((part + 1) * frequency) - (span != 0 ? (frequency - span) : 0)); - // --- Warp to the current time + // Warp to the current time vm.warp(currentTime); uint256 validTo = TWAPOrderMathLib.calculateValidTo(startTime, numParts, frequency, span); @@ -625,8 +639,6 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { assertTrue(validTo == expectedValidTo); } - // --- Helper functions --- - function createOrder(Safe safe, TWAPOrder.Data memory twapBundle, IERC20 sellToken, uint256 sellAmount) internal returns (IConditionalOrder.ConditionalOrderParams memory params) From 838e0c6f5ab3b408dbb6c0c6274c07081b457b06 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 18:35:22 +0000 Subject: [PATCH 16/25] docs: update architecture documentation for dual-path design - Document settlement path vs polling path separation - Add PollResultCode semantics table with PARTIALLY_FILLED and FILLED - Document fill detection via GPv2Settlement - Add polling path diagram with fill checking flow - Document nextPollTimestamp semantics - Add implementation checklist for new order types --- docs/architecture.md | 276 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 docs/architecture.md diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..350bbc5 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,276 @@ +# Composable CoW Architecture + +## Overview + +Composable CoW is a framework for creating conditional orders on CoW Protocol. It enables Safe wallets to define orders that become tradeable when specific conditions are met (price thresholds, time windows, balance triggers, etc.). + +The architecture separates two distinct execution paths: + +1. **Settlement Path** - On-chain verification during trade execution (gas-sensitive) +2. **Polling Path** - Off-chain queries by watch-towers (gas-irrelevant) + +This separation ensures settlement remains gas-efficient while providing rich metadata for off-chain infrastructure. + +## Design Principles + +1. **Single source of truth**: `generateOrder()` contains all order generation logic +2. **Lean settlement**: No metadata structs, only constant string errors for debugging +3. **Rich polling**: Structured results with scheduling hints for watch-towers +4. **No code duplication**: Polling wraps the same core logic used by settlement + +## Interface Hierarchy + +``` +IConditionalOrder +├── Errors (with string reasons for debugging) +│ ├── OrderNotValid(string reason) +│ ├── PollTryNextBlock(string reason) +│ ├── PollTryAtTimestamp(uint256 timestamp, string reason) +│ └── PollTryAtBlock(uint256 blockNumber, string reason) +├── ConditionalOrderParams struct +├── generateOrder() - core order generation +└── verify() - settlement validation + +IConditionalOrderGenerator : IConditionalOrder, IERC165 +├── PollResultCode enum +├── PollResult struct +├── poll() - rich polling with metadata +├── getNextPollTimestamp() - scheduling hints +└── describeOrder() - human-readable status +``` + +## Execution Paths + +### Settlement Path (On-Chain) + +``` +CoW Settlement + │ + ▼ +Safe.isValidSignature(hash, signature) + │ + ▼ +ExtensibleFallbackHandler + │ + ▼ +ComposableCoW.isValidSafeSignature(...) + │ + ├── _auth() Verify merkle proof or single order + ├── _guardCheck() Optional swap guard + │ + └── handler.verify(...) LEAN PATH + │ + ▼ + generateOrder() Core logic, reverts if invalid + │ + └── hash check Verify order matches +``` + +**Gas considerations**: +- Error reasons use constant strings (minimal allocation) +- No PollResult construction +- No polling metadata calls +- Minimal computation beyond core validation + +### Polling Path (Off-Chain) + +``` +Watch-Tower + │ + ▼ +ComposableCoW.getTradeableOrderWithSignature(...) + │ + ├── _auth() Verify authorization + │ + └── handler.poll(...) RICH PATH + │ + ▼ + try generateOrder() Same core logic + │ + ├── Success: + │ ├── getNextPollTimestamp() + │ ├── describeOrder() + │ └── return PollResult(SUCCESS, order, hints) + │ + └── Revert: + ├── decode error selector + └── return PollResult(WAIT_*, waitUntil, reason) + │ + ▼ (on SUCCESS) +_getFilledAmount() Check GPv2Settlement + │ + ├── filledAmount >= totalAmount: + │ └── return PollResult(FILLED, order, filledAmount) + │ + ├── filledAmount > 0: + │ └── return PollResult(PARTIALLY_FILLED, order, filledAmount) + │ + └── filledAmount == 0: + ├── _guardCheck() Optional swap guard + └── _buildSignature() Build EIP-1271 signature +``` + +**Characteristics**: +- Returns structured `PollResult`, never reverts for order conditions +- Includes scheduling hints (`nextPollTimestamp`, `waitUntil`) +- Human-readable reasons for debugging +- Checks GPv2Settlement for fill status before building signature + +## Error Types + +Errors include string reasons for debugging. Using constant strings (e.g., `INVALID_HASH`, `BEFORE_TWAP_START`) minimizes gas overhead while improving debuggability: + +| Error | Meaning | Watch-tower Action | +|-------|---------|-------------------| +| `OrderNotValid(string)` | Permanent failure | Stop polling | +| `PollTryNextBlock(string)` | Transient, retry soon | Poll next block | +| `PollTryAtTimestamp(uint256, string)` | Wait for time | Schedule at timestamp | +| `PollTryAtBlock(uint256, string)` | Wait for block | Schedule at block | + +## PollResult Structure + +```solidity +enum PollResultCode { + SUCCESS, // Order ready to trade + PARTIALLY_FILLED, // Order partially filled, no action needed (informational) + FILLED, // Order completely filled, no action needed + WAIT_TIMESTAMP, // Not ready, wait for timestamp + WAIT_BLOCK, // Not ready, wait for block + TRY_NEXT_BLOCK, // Not ready, transient condition + INVALID // Permanently invalid, stop polling +} + +struct PollResult { + PollResultCode code; + GPv2Order.Data order; // Valid when SUCCESS, PARTIALLY_FILLED, or FILLED + uint256 nextPollTimestamp; // When to poll for next order + uint256 waitUntil; // For WAIT_*: when to retry + string reason; // Human-readable (off-chain only) + uint256 filledAmount; // PARTIALLY_FILLED/FILLED: amount filled +} +``` + +### PollResultCode Semantics + +| Code | Meaning | Watch-tower Action | +|------|---------|-------------------| +| `SUCCESS` | Order ready to trade | Submit to CoW Protocol API | +| `PARTIALLY_FILLED` | Order partially filled | No action, informational only | +| `FILLED` | Order completely filled | No action, informational only | +| `WAIT_TIMESTAMP` | Wait for specific time | Schedule poll at `waitUntil` | +| `WAIT_BLOCK` | Wait for specific block | Schedule poll at block `waitUntil` | +| `TRY_NEXT_BLOCK` | Transient condition | Poll again next block | +| `INVALID` | Permanently invalid | Stop polling this order | + +### nextPollTimestamp Semantics + +| Value | Meaning | +|-------|---------| +| `0` | Use `order.validTo + 1` as default | +| `> 0` | Poll at this specific timestamp | +| `type(uint256).max` | Final order, stop polling after fill | + +## Order Type Patterns + +### Single-Shot Orders (StopLoss, GoodAfterTime) + +```solidity +function generateOrder(...) public view returns (GPv2Order.Data memory) { + // Validate conditions - use require with custom errors (Solidity 0.8.30+) + require(!expired, OrderNotValid("order expired")); + require(conditionMet, PollTryNextBlock("condition not met")); + + // Build and return order + return GPv2Order.Data(...); +} + +function getNextPollTimestamp(...) external pure returns (uint256) { + return type(uint256).max; // POLL_NEVER - single shot +} +``` + +### Multi-Part Orders (TWAP) + +```solidity +function generateOrder(...) public view returns (GPv2Order.Data memory) { + require(block.timestamp >= startTime, PollTryAtTimestamp(startTime, "before twap start")); + require(block.timestamp < endTime, OrderNotValid("twap finished")); + + // Calculate current part and build order + return buildPartOrder(currentPart); +} + +function getNextPollTimestamp(...) external view returns (uint256) { + uint256 currentPart = calculateCurrentPart(); + if (currentPart == lastPart) return type(uint256).max; + return startTime + ((currentPart + 1) * frequency); +} +``` + +### Perpetual Orders (PerpetualStableSwap) + +```solidity +function generateOrder(...) public view returns (GPv2Order.Data memory) { + require(funded, OrderNotValid("not funded")); + return GPv2Order.Data(...); +} + +function getNextPollTimestamp(...) external pure returns (uint256) { + return 0; // Use validTo + 1, perpetually repeating +} +``` + +## ComposableCoW Contract + +### Key Functions + +| Function | Path | Returns | +|----------|------|---------| +| `isValidSafeSignature()` | Settlement | `bytes4` magic value | +| `getTradeableOrderWithSignature()` | Polling | `(PollResult, signature)` | +| `checkOrder()` | Polling | `(PollResultCode, waitUntil)` | + +### Authorization + +Orders are authorized via: +- **Single orders**: `singleOrders[owner][hash(params)] = true` +- **Merkle roots**: `roots[owner] = merkleRoot` + +The `_auth()` function verifies authorization and returns the context key: +- Merkle orders: `ctx = bytes32(0)` +- Single orders: `ctx = hash(params)` + +### Context Storage (Cabinet) + +The `cabinet` mapping stores per-order context: +```solidity +mapping(address owner => mapping(bytes32 ctx => bytes32 value)) public cabinet; +``` + +Used by TWAP to store dynamic start times set at order creation. + +## Implementation Checklist for New Order Types + +1. Extend `BaseConditionalOrder` +2. Implement `generateOrder()`: + - Validate conditions using `require(condition, CustomError(reason))` + - Use constant string reasons (e.g., `string constant MY_ERROR = "my error"`) + - Build and return `GPv2Order.Data` +3. Override `getNextPollTimestamp()` if not using default: + - Return `0` for "use validTo + 1" + - Return `type(uint256).max` for single-shot orders + - Return specific timestamp for multi-part orders +4. Optionally override `describeOrder()` for better UX + +## Gas Comparison + +| Operation | Settlement Path | Polling Path | +|-----------|----------------|--------------| +| `generateOrder()` | Yes | Yes | +| Hash verification | Yes | No | +| `getNextPollTimestamp()` | No | Yes | +| `describeOrder()` | No | Yes | +| Error reason strings | Yes (constants) | Yes (constants) | +| PollResult construction | No | Yes | + +The settlement path only executes what's necessary for validation. Error reason strings use compile-time constants to minimize gas overhead while providing useful debugging information. From 5ecf8c4079f8ed1a99ea68883ba94b210d2bc162 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 6 Feb 2026 19:00:09 +0000 Subject: [PATCH 17/25] fix: resolve divide-before-multiply lint warning Restructure validToBucket() to calculate bucket number first, then multiply. Same behavior, clearer intent, no lint warning. --- src/types/ConditionalOrdersUtilsLib.sol | 26 +++++++++++-------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/types/ConditionalOrdersUtilsLib.sol b/src/types/ConditionalOrdersUtilsLib.sol index f00b24f..5a5f7b7 100644 --- a/src/types/ConditionalOrdersUtilsLib.sol +++ b/src/types/ConditionalOrdersUtilsLib.sol @@ -1,27 +1,23 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.0 <0.9.0; -/** - * @title ConditionalOrdersUtilsLib - Utility functions for standardising conditional orders. - * @author mfw78 - */ +/// @title ConditionalOrdersUtilsLib - Utility functions for standardising conditional orders +/// @author mfw78 library ConditionalOrdersUtilsLib { uint256 constant MAX_BPS = 10000; - /** - * Given the width of the validity bucket, return the timestamp of the *end* of the bucket. - * @param validity The width of the validity bucket in seconds. - */ + /// @notice Given the width of the validity bucket, return the timestamp of the *end* of the bucket. + /// @param validity The width of the validity bucket in seconds. function validToBucket(uint32 validity) internal view returns (uint32 validTo) { - validTo = ((uint32(block.timestamp) / validity) * validity) + validity; + // Calculate which bucket we're in, then return the end of that bucket + uint32 currentBucket = uint32(block.timestamp) / validity; + validTo = (currentBucket + 1) * validity; } - /** - * Given a price returned by a chainlink-like oracle, scale it to the desired amount of decimals - * @param oraclePrice return by a chainlink-like oracle - * @param fromDecimals the decimals the oracle returned (e.g. 8 for USDC) - * @param toDecimals the amount of decimals the price should be scaled to - */ + /// @notice Scale a price from one decimal precision to another + /// @param oraclePrice Price returned by a chainlink-like oracle + /// @param fromDecimals The decimals the oracle returned (e.g. 8 for USDC) + /// @param toDecimals The amount of decimals the price should be scaled to function scalePrice(int256 oraclePrice, uint8 fromDecimals, uint8 toDecimals) internal pure returns (int256) { if (fromDecimals < toDecimals) { return oraclePrice * int256(10 ** uint256(toDecimals - fromDecimals)); From eaeb0829fc6932cc98f6e441da711c1f8dd2669c Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 13 Feb 2026 10:07:47 +0000 Subject: [PATCH 18/25] chore: ignore build output directory Add out/ to .gitignore to exclude Foundry build artifacts. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 244a3ef..ef993a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Compiler files cache/ +out/ # Ignores development broadcast logs !/broadcast From 70c8a6f67cb264ce347ced6a2a7aaae31df89aea Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 13 Feb 2026 10:08:02 +0000 Subject: [PATCH 19/25] feat: add IOrderManifest interface for order enumeration Introduces a new interface enabling enumeration of all discrete orders that a conditional order will produce. Useful for analytics, UI previews, and order lifecycle tracking. - Cardinality enum: FINITE, BOUNDED, UNBOUNDED - ManifestInfo struct for high-level order count information - ManifestEntry struct with order details and validity window - Paginated getManifestPage() for efficient enumeration --- src/interfaces/IOrderManifest.sol | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/interfaces/IOrderManifest.sol diff --git a/src/interfaces/IOrderManifest.sol b/src/interfaces/IOrderManifest.sol new file mode 100644 index 0000000..99c9b82 --- /dev/null +++ b/src/interfaces/IOrderManifest.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {GPv2Order} from "cowprotocol/contracts/libraries/GPv2Order.sol"; + +/// @title IOrderManifest - Interface for order enumeration +/// @author mfw78 +/// @notice Allows enumeration of all discrete orders that a conditional order will produce. +/// @dev Useful for analytics, UI preview, and order lifecycle tracking. +interface IOrderManifest { + /// @notice Describes the cardinality of orders produced by a conditional order + enum Cardinality { + FINITE, // Known fixed number of orders (e.g., TWAP with n parts) + BOUNDED, // Upper bound known, actual count is dynamic + UNBOUNDED // Potentially infinite orders (e.g., PerpetualStableSwap) + } + + /// @notice High-level information about the order manifest + /// @param cardinality The cardinality type of this conditional order + /// @param totalOrders Exact count for FINITE, max for BOUNDED, 0 for UNBOUNDED + struct ManifestInfo { + Cardinality cardinality; + uint256 totalOrders; + } + + /// @notice A single entry in the manifest representing one discrete order + /// @param index The index of this order (0-indexed) + /// @param order The GPv2Order data for this discrete order + /// @param validFrom When this order becomes valid (since GPv2Order only has validTo) + /// @param isActive Whether this order is currently active (within its validity window) + struct ManifestEntry { + uint256 index; + GPv2Order.Data order; + uint256 validFrom; + bool isActive; + } + + /// @notice Get high-level information about the order manifest + /// @dev Returns cardinality and total order count for this conditional order. + /// @param owner The owner of the conditional order + /// @param ctx Context key (bytes32(0) for merkle, hash(params) for single) + /// @param staticInput The static input parameters for the conditional order + /// @return info The manifest information + function getManifestInfo(address owner, bytes32 ctx, bytes calldata staticInput) + external + view + returns (ManifestInfo memory info); + + /// @notice Get a paginated list of manifest entries + /// @dev For FINITE orders, returns all orders within pagination bounds. + /// For UNBOUNDED orders, returns current tradeable order with hasMore=true. + /// @param owner The owner of the conditional order + /// @param ctx Context key (bytes32(0) for merkle, hash(params) for single) + /// @param staticInput The static input parameters for the conditional order + /// @param offchainInput Dynamic parameters from watch-tower (may be empty) + /// @param offset Starting index for pagination + /// @param limit Maximum number of entries to return + /// @return entries Array of manifest entries + /// @return hasMore Whether more entries exist beyond this page + function getManifestPage( + address owner, + bytes32 ctx, + bytes calldata staticInput, + bytes calldata offchainInput, + uint256 offset, + uint256 limit + ) external view returns (ManifestEntry[] memory entries, bool hasMore); +} From 08a17445acad6564c9dcec36223ad4fbd40e5a8e Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 13 Feb 2026 10:08:13 +0000 Subject: [PATCH 20/25] feat: add default IOrderManifest implementation to BaseConditionalOrder Provides a default single-shot manifest implementation: - getManifestInfo() returns FINITE with totalOrders: 1 - getManifestPage() wraps generateOrder() for a single entry - Adds IOrderManifest to supportsInterface() Order types can override these for multi-part or unbounded orders. --- src/BaseConditionalOrder.sol | 54 ++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/BaseConditionalOrder.sol b/src/BaseConditionalOrder.sol index 5083c97..a050696 100644 --- a/src/BaseConditionalOrder.sol +++ b/src/BaseConditionalOrder.sol @@ -4,13 +4,15 @@ pragma solidity >=0.8.0 <0.9.0; import {GPv2Order, IERC20} from "cowprotocol/contracts/libraries/GPv2Order.sol"; import {IERC165, IConditionalOrder, IConditionalOrderGenerator} from "./interfaces/IConditionalOrder.sol"; +import {IOrderManifest} from "./interfaces/IOrderManifest.sol"; string constant INVALID_HASH = "invalid hash"; /// @title BaseConditionalOrder - Base implementation for conditional orders /// @author mfw78 /// @notice Provides dual-path support: lean verify() for settlement, rich poll() for watch-towers -abstract contract BaseConditionalOrder is IConditionalOrderGenerator { +/// @dev Includes default IOrderManifest implementation for single-shot orders +abstract contract BaseConditionalOrder is IConditionalOrderGenerator, IOrderManifest { /// @dev Signals poll() to use order.validTo + 1 as next poll time uint256 internal constant POLL_AT_VALIDTO = 0; /// @dev Signals poll() that this is the final order, stop polling after fill @@ -87,9 +89,57 @@ abstract contract BaseConditionalOrder is IConditionalOrderGenerator { bytes calldata offchainInput ) public view virtual override returns (GPv2Order.Data memory order); + // ============ IOrderManifest Default Implementation ============ + + /// @inheritdoc IOrderManifest + /// @dev Default: single-shot order (FINITE with 1 order). Override for multi-part orders. + function getManifestInfo(address, bytes32, bytes calldata) + external + view + virtual + override + returns (ManifestInfo memory info) + { + info = ManifestInfo({cardinality: Cardinality.FINITE, totalOrders: 1}); + } + + /// @inheritdoc IOrderManifest + /// @dev Default: wraps generateOrder() for single entry. Override for multi-part orders. + function getManifestPage( + address owner, + bytes32 ctx, + bytes calldata staticInput, + bytes calldata offchainInput, + uint256 offset, + uint256 limit + ) external view virtual override returns (ManifestEntry[] memory entries, bool hasMore) { + // Single-shot: only index 0 exists + if (offset > 0 || limit == 0) { + return (new ManifestEntry[](0), false); + } + + // Try to generate the order + try this.generateOrder(owner, address(0), ctx, staticInput, offchainInput) returns ( + GPv2Order.Data memory order + ) { + entries = new ManifestEntry[](1); + entries[0] = ManifestEntry({ + index: 0, + order: order, + validFrom: 0, // Single-shot orders are valid immediately (no explicit validFrom) + isActive: block.timestamp <= order.validTo + }); + hasMore = false; + } catch { + // If order generation fails, return empty (order not ready or invalid) + return (new ManifestEntry[](0), false); + } + } + /// @inheritdoc IERC165 function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) { - return interfaceId == type(IConditionalOrderGenerator).interfaceId || interfaceId == type(IERC165).interfaceId; + return interfaceId == type(IConditionalOrderGenerator).interfaceId + || interfaceId == type(IOrderManifest).interfaceId || interfaceId == type(IERC165).interfaceId; } /// @dev Decode revert data into a PollResult From b25f86189a360ff7264dc9513dcbc291976985d0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 13 Feb 2026 10:08:46 +0000 Subject: [PATCH 21/25] feat: implement IOrderManifest for all order types TWAP: - FINITE cardinality with n parts - Full pagination support for all TWAP parts - Consolidated helpers for order construction StopLoss: - Custom manifest showing order structure even when strike not reached - Helper to check strike condition without reverting GoodAfterTime: - Sets validFrom to startTime for proper scheduling display TradeAboveThreshold: - Shows order structure even when below threshold PerpetualStableSwap: - UNBOUNDED cardinality with hasMore always true --- src/types/GoodAfterTime.sol | 38 +++++++ src/types/PerpetualStableSwap.sol | 68 ++++++++++- src/types/StopLoss.sol | 94 ++++++++++++++++ src/types/TradeAboveThreshold.sol | 51 ++++++++- src/types/twap/TWAP.sol | 180 ++++++++++++++++++++++++++---- 5 files changed, 407 insertions(+), 24 deletions(-) diff --git a/src/types/GoodAfterTime.sol b/src/types/GoodAfterTime.sol index 7941afb..7c55865 100644 --- a/src/types/GoodAfterTime.sol +++ b/src/types/GoodAfterTime.sol @@ -11,6 +11,7 @@ import { GPv2Order, BaseConditionalOrder } from "../BaseConditionalOrder.sol"; +import {IOrderManifest} from "../interfaces/IOrderManifest.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; string constant TOO_EARLY = "too early"; @@ -105,4 +106,41 @@ contract GoodAfterTime is BaseConditionalOrder { { return "good-after-time order ready"; } + + // ============ IOrderManifest Override ============ + + /// @inheritdoc IOrderManifest + /// @dev Custom implementation to properly set validFrom to startTime + function getManifestPage( + address owner, + bytes32 ctx, + bytes calldata staticInput, + bytes calldata offchainInput, + uint256 offset, + uint256 limit + ) external view override returns (ManifestEntry[] memory entries, bool hasMore) { + // Single-shot: only index 0 exists + if (offset > 0 || limit == 0) { + return (new ManifestEntry[](0), false); + } + + Data memory data = abi.decode(staticInput, (Data)); + + // Try to generate the order (may fail if before startTime or conditions not met) + try this.generateOrder(owner, address(0), ctx, staticInput, offchainInput) returns ( + GPv2Order.Data memory order + ) { + entries = new ManifestEntry[](1); + entries[0] = ManifestEntry({ + index: 0, + order: order, + validFrom: data.startTime, + isActive: block.timestamp >= data.startTime && block.timestamp <= data.endTime + }); + hasMore = false; + } catch { + // If order generation fails, return empty + return (new ManifestEntry[](0), false); + } + } } diff --git a/src/types/PerpetualStableSwap.sol b/src/types/PerpetualStableSwap.sol index f9e02ae..8156fff 100644 --- a/src/types/PerpetualStableSwap.sol +++ b/src/types/PerpetualStableSwap.sol @@ -1,7 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.0 <0.9.0; -import {IERC20, GPv2Order, IConditionalOrder, BaseConditionalOrder} from "../BaseConditionalOrder.sol"; +import { + IERC20, + GPv2Order, + IConditionalOrder, + IConditionalOrderGenerator, + BaseConditionalOrder +} from "../BaseConditionalOrder.sol"; +import {IOrderManifest} from "../interfaces/IOrderManifest.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; /// @title PerpetualStableSwap - 1:1 swaps between token pairs with spread @@ -51,7 +58,64 @@ contract PerpetualStableSwap is BaseConditionalOrder { GPv2Order.BALANCE_ERC20 ); } - // Uses default getNextPollTimestamp() and describeOrder() from BaseConditionalOrder + + /// @inheritdoc IConditionalOrderGenerator + function describeOrder(address, bytes32, bytes calldata, GPv2Order.Data memory) + external + pure + override + returns (string memory) + { + return "perpetual stable swap ready"; + } + + // ============ IOrderManifest Override (UNBOUNDED) ============ + + /// @inheritdoc IOrderManifest + /// @dev Perpetual orders have unbounded cardinality - they keep producing orders indefinitely + function getManifestInfo(address, bytes32, bytes calldata) + external + pure + override + returns (ManifestInfo memory info) + { + info = ManifestInfo({cardinality: Cardinality.UNBOUNDED, totalOrders: 0}); + } + + /// @inheritdoc IOrderManifest + /// @dev Returns current tradeable order with hasMore=true (always more orders possible) + function getManifestPage( + address owner, + bytes32 ctx, + bytes calldata staticInput, + bytes calldata offchainInput, + uint256 offset, + uint256 limit + ) external view override returns (ManifestEntry[] memory entries, bool hasMore) { + // For unbounded orders, we only return the current order at offset 0 + if (offset > 0 || limit == 0) { + return (new ManifestEntry[](0), true); // hasMore is always true for unbounded + } + + // Try to generate the current order + try this.generateOrder(owner, address(0), ctx, staticInput, offchainInput) returns ( + GPv2Order.Data memory order + ) { + entries = new ManifestEntry[](1); + entries[0] = ManifestEntry({ + index: 0, // Always 0 for unbounded (current order) + order: order, + validFrom: 0, // Valid immediately + isActive: block.timestamp <= order.validTo + }); + hasMore = true; // Perpetual orders always have more + } catch { + // If order generation fails (e.g., not funded), return empty but still hasMore + return (new ManifestEntry[](0), true); + } + } + + // ============ Internal Functions ============ function side(address owner, Data memory data) internal view returns (BuySellData memory buySellData) { IERC20 tokenA = IERC20(address(data.tokenA)); diff --git a/src/types/StopLoss.sol b/src/types/StopLoss.sol index f594a06..e34fc7e 100644 --- a/src/types/StopLoss.sol +++ b/src/types/StopLoss.sol @@ -8,6 +8,7 @@ import { IConditionalOrderGenerator, BaseConditionalOrder } from "../BaseConditionalOrder.sol"; +import {IOrderManifest} from "../interfaces/IOrderManifest.sol"; import {IAggregatorV3Interface} from "../interfaces/IAggregatorV3Interface.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; @@ -106,4 +107,97 @@ contract StopLoss is BaseConditionalOrder { { return "stop-loss triggered"; } + + // ============ IOrderManifest Override ============ + + /// @inheritdoc IOrderManifest + /// @dev Custom implementation that shows order structure even when strike not reached + function getManifestPage( + address owner, + bytes32 ctx, + bytes calldata staticInput, + bytes calldata offchainInput, + uint256 offset, + uint256 limit + ) external view override returns (ManifestEntry[] memory entries, bool hasMore) { + // Single-shot: only index 0 exists + if (offset > 0 || limit == 0) { + return (new ManifestEntry[](0), false); + } + + Data memory data = abi.decode(staticInput, (Data)); + + // Check if order has expired + if (data.validTo < block.timestamp) { + return (new ManifestEntry[](0), false); + } + + // Build the order structure (without condition checks) + GPv2Order.Data memory order = GPv2Order.Data( + data.sellToken, + data.buyToken, + data.receiver, + data.sellAmount, + data.buyAmount, + data.validTo, + data.appData, + 0, + data.isSellOrder ? GPv2Order.KIND_SELL : GPv2Order.KIND_BUY, + data.isPartiallyFillable, + GPv2Order.BALANCE_ERC20, + GPv2Order.BALANCE_ERC20 + ); + + // Check if currently active (strike reached and oracles valid) + bool isActive = _checkStrikeCondition(data); + + entries = new ManifestEntry[](1); + entries[0] = ManifestEntry({ + index: 0, + order: order, + validFrom: 0, // Condition-based, valid immediately when strike hit + isActive: isActive + }); + hasMore = false; + } + + /// @dev Check if strike condition is currently met (without reverting) + function _checkStrikeCondition(Data memory data) internal view returns (bool) { + // Check expiry + if (data.validTo < block.timestamp) { + return false; + } + + // Get oracle prices + try data.sellTokenPriceOracle.latestRoundData() returns ( + uint80, int256 basePrice, uint256, uint256 sellUpdatedAt, uint80 + ) { + try data.buyTokenPriceOracle.latestRoundData() returns ( + uint80, int256 quotePrice, uint256, uint256 buyUpdatedAt, uint80 + ) { + // Check price validity + if (basePrice <= 0 || quotePrice <= 0) { + return false; + } + + // Check staleness + if ( + sellUpdatedAt < block.timestamp - data.maxTimeSinceLastOracleUpdate + || buyUpdatedAt < block.timestamp - data.maxTimeSinceLastOracleUpdate + ) { + return false; + } + + // Scale prices and check strike + int256 scaledBasePrice = Utils.scalePrice(basePrice, data.sellTokenPriceOracle.decimals(), 18); + int256 scaledQuotePrice = Utils.scalePrice(quotePrice, data.buyTokenPriceOracle.decimals(), 18); + + return scaledBasePrice * SCALING_FACTOR / scaledQuotePrice <= data.strike; + } catch { + return false; + } + } catch { + return false; + } + } } diff --git a/src/types/TradeAboveThreshold.sol b/src/types/TradeAboveThreshold.sol index cefd638..3087631 100644 --- a/src/types/TradeAboveThreshold.sol +++ b/src/types/TradeAboveThreshold.sol @@ -2,6 +2,7 @@ pragma solidity >=0.8.0 <0.9.0; import {IERC20, GPv2Order, IConditionalOrder, BaseConditionalOrder} from "../BaseConditionalOrder.sol"; +import {IOrderManifest} from "../interfaces/IOrderManifest.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; import {BALANCE_INSUFFICIENT} from "./GoodAfterTime.sol"; @@ -45,5 +46,53 @@ contract TradeAboveThreshold is BaseConditionalOrder { GPv2Order.BALANCE_ERC20 ); } - // Uses default getNextPollTimestamp() and describeOrder() from BaseConditionalOrder + + // ============ IOrderManifest Override ============ + + /// @inheritdoc IOrderManifest + /// @dev Custom implementation that shows order structure even when threshold not met + function getManifestPage( + address owner, + bytes32, + bytes calldata staticInput, + bytes calldata, + uint256 offset, + uint256 limit + ) external view override returns (ManifestEntry[] memory entries, bool hasMore) { + // Single-shot: only index 0 exists + if (offset > 0 || limit == 0) { + return (new ManifestEntry[](0), false); + } + + Data memory data = abi.decode(staticInput, (Data)); + uint256 balance = data.sellToken.balanceOf(owner); + bool thresholdMet = balance >= data.threshold; + + // Build the order structure with current balance (or threshold if below) + uint256 sellAmount = thresholdMet ? balance : data.threshold; + + GPv2Order.Data memory order = GPv2Order.Data( + data.sellToken, + data.buyToken, + data.receiver, + sellAmount, + 1, + Utils.validToBucket(data.validityBucketSeconds), + data.appData, + 0, + GPv2Order.KIND_SELL, + false, + GPv2Order.BALANCE_ERC20, + GPv2Order.BALANCE_ERC20 + ); + + entries = new ManifestEntry[](1); + entries[0] = ManifestEntry({ + index: 0, + order: order, + validFrom: 0, // Condition-based, valid when threshold met + isActive: thresholdMet + }); + hasMore = false; + } } diff --git a/src/types/twap/TWAP.sol b/src/types/twap/TWAP.sol index ef67968..562a0b5 100644 --- a/src/types/twap/TWAP.sol +++ b/src/types/twap/TWAP.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + import {ComposableCoW} from "../../ComposableCoW.sol"; import { IConditionalOrder, @@ -8,6 +10,8 @@ import { GPv2Order, BaseConditionalOrder } from "../../BaseConditionalOrder.sol"; +import {IOrderManifest} from "../../interfaces/IOrderManifest.sol"; +import {IERC165} from "../../interfaces/IConditionalOrder.sol"; import {TWAPOrder} from "./libraries/TWAPOrder.sol"; import {TWAPOrderMathLib, AFTER_TWAP_FINISH} from "./libraries/TWAPOrderMathLib.sol"; @@ -17,12 +21,95 @@ string constant NOT_WITHIN_SPAN = "outside span"; /// @author mfw78 /// @notice Splits an order into multiple parts executed at fixed intervals. contract TWAP is BaseConditionalOrder { + using SafeCast for uint256; + ComposableCoW public immutable composableCow; constructor(ComposableCoW _composableCow) { composableCow = _composableCow; } + // ============ Internal Helpers ============ + + /// @dev Decode staticInput and resolve t0 from cabinet if needed + function _resolveTwapData(address owner, bytes32 ctx, bytes calldata staticInput) + internal + view + returns (TWAPOrder.Data memory twap) + { + twap = abi.decode(staticInput, (TWAPOrder.Data)); + if (twap.t0 == 0) { + twap.t0 = uint256(composableCow.cabinet(owner, ctx)); + } + } + + /// @dev Get the current part index from block.timestamp + function _currentPart(TWAPOrder.Data memory twap) internal view returns (uint256) { + return TWAPOrderMathLib.currentPart(twap.t0, twap.t); + } + + /// @dev Calculate validFrom and validTo for any part index (pure, for manifest enumeration) + /// @return validFrom The start timestamp for this part + /// @return validTo The end timestamp for this part (inclusive) + function _partTiming(TWAPOrder.Data memory twap, uint256 partIndex) + internal + pure + returns (uint256 validFrom, uint256 validTo) + { + validFrom = twap.t0 + (partIndex * twap.t); + + if (twap.span == 0) { + // Full epoch: valid until next part starts + validTo = validFrom + twap.t - 1; + } else { + // Partial span within epoch + validTo = validFrom + twap.span - 1; + } + } + + /// @dev Build GPv2Order.Data for any part index (pure, for manifest enumeration) + /// @dev Does NOT check runtime conditions (before/after TWAP window). Use only for manifest. + function _orderForPartPure(TWAPOrder.Data memory twap, uint256 partIndex) + internal + pure + returns (GPv2Order.Data memory order) + { + (, uint256 validTo) = _partTiming(twap, partIndex); + + order = GPv2Order.Data({ + sellToken: twap.sellToken, + buyToken: twap.buyToken, + receiver: twap.receiver, + sellAmount: twap.partSellAmount, + buyAmount: twap.minPartLimit, + validTo: validTo.toUint32(), + appData: twap.appData, + feeAmount: 0, + kind: GPv2Order.KIND_SELL, + partiallyFillable: false, + sellTokenBalance: GPv2Order.BALANCE_ERC20, + buyTokenBalance: GPv2Order.BALANCE_ERC20 + }); + } + + /// @dev Build a complete ManifestEntry for any part index + function _manifestEntry(TWAPOrder.Data memory twap, uint256 partIndex) + internal + view + returns (ManifestEntry memory entry) + { + (uint256 validFrom, uint256 validTo) = _partTiming(twap, partIndex); + + entry = ManifestEntry({ + index: partIndex, + order: _orderForPartPure(twap, partIndex), + validFrom: validFrom, + isActive: block.timestamp >= validFrom && block.timestamp <= validTo + }); + } + + // ============ IConditionalOrder Implementation ============ + /// @inheritdoc IConditionalOrder function generateOrder(address owner, address, bytes32 ctx, bytes calldata staticInput, bytes calldata) public @@ -30,19 +117,14 @@ contract TWAP is BaseConditionalOrder { override returns (GPv2Order.Data memory order) { - TWAPOrder.Data memory twap = abi.decode(staticInput, (TWAPOrder.Data)); - - // Get start time from cabinet if not specified - if (twap.t0 == 0) { - twap.t0 = uint256(composableCow.cabinet(owner, ctx)); - } + TWAPOrder.Data memory twap = _resolveTwapData(owner, ctx, staticInput); + // Use TWAPOrder.orderFor which includes all runtime checks (before start, after finish) order = TWAPOrder.orderFor(twap); // Check if outside the TWAP part's span if (block.timestamp > order.validTo) { - // Calculate next part start time - uint256 part = TWAPOrderMathLib.currentPart(twap.t0, twap.t); + uint256 part = _currentPart(twap); uint256 nextPartStart = twap.t0 + ((part + 1) * twap.t); uint256 endTime = twap.t0 + (twap.n * twap.t); @@ -51,6 +133,8 @@ contract TWAP is BaseConditionalOrder { } } + // ============ IConditionalOrderGenerator Implementation ============ + /// @inheritdoc IConditionalOrderGenerator function getNextPollTimestamp(address owner, bytes32 ctx, bytes calldata staticInput, GPv2Order.Data memory) external @@ -58,13 +142,8 @@ contract TWAP is BaseConditionalOrder { override returns (uint256) { - TWAPOrder.Data memory twap = abi.decode(staticInput, (TWAPOrder.Data)); - - if (twap.t0 == 0) { - twap.t0 = uint256(composableCow.cabinet(owner, ctx)); - } - - uint256 part = TWAPOrderMathLib.currentPart(twap.t0, twap.t); + TWAPOrder.Data memory twap = _resolveTwapData(owner, ctx, staticInput); + uint256 part = _currentPart(twap); // Last part - stop polling after this fills if (part >= twap.n - 1) { @@ -82,17 +161,76 @@ contract TWAP is BaseConditionalOrder { override returns (string memory) { - TWAPOrder.Data memory twap = abi.decode(staticInput, (TWAPOrder.Data)); + TWAPOrder.Data memory twap = _resolveTwapData(owner, ctx, staticInput); + uint256 part = _currentPart(twap); + + if (part >= twap.n - 1) { + return "final twap part"; + } + return "twap part ready"; + } + + // ============ IOrderManifest Implementation ============ + + /// @inheritdoc IOrderManifest + function getManifestInfo(address owner, bytes32 ctx, bytes calldata staticInput) + external + view + override + returns (ManifestInfo memory info) + { + TWAPOrder.Data memory twap = _resolveTwapData(owner, ctx, staticInput); + + // TWAP always has exactly n parts + info = ManifestInfo({cardinality: Cardinality.FINITE, totalOrders: twap.n}); + } + /// @inheritdoc IOrderManifest + function getManifestPage( + address owner, + bytes32 ctx, + bytes calldata staticInput, + bytes calldata, + uint256 offset, + uint256 limit + ) external view override returns (ManifestEntry[] memory entries, bool hasMore) { + TWAPOrder.Data memory twap = _resolveTwapData(owner, ctx, staticInput); + + // If t0 is still 0 after resolution, the order hasn't been initialized if (twap.t0 == 0) { - twap.t0 = uint256(composableCow.cabinet(owner, ctx)); + return (new ManifestEntry[](0), false); } - uint256 part = TWAPOrderMathLib.currentPart(twap.t0, twap.t); + // Validate order parameters + try this.validateTwapData(twap) {} + catch { + return (new ManifestEntry[](0), false); + } - if (part >= twap.n - 1) { - return "final twap part"; + // Calculate pagination bounds + uint256 totalParts = twap.n; + if (offset >= totalParts) { + return (new ManifestEntry[](0), false); } - return "twap part ready"; + + uint256 remaining = totalParts - offset; + uint256 count = remaining < limit ? remaining : limit; + hasMore = offset + count < totalParts; + + entries = new ManifestEntry[](count); + for (uint256 i = 0; i < count; i++) { + entries[i] = _manifestEntry(twap, offset + i); + } + } + + /// @dev External wrapper for validation (used with try/catch) + function validateTwapData(TWAPOrder.Data memory twap) external pure { + TWAPOrder.validate(twap); + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) { + return interfaceId == type(IConditionalOrderGenerator).interfaceId + || interfaceId == type(IOrderManifest).interfaceId || interfaceId == type(IERC165).interfaceId; } } From 05dbfded2916243b8b163f49ec275688d5babecf Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 13 Feb 2026 10:09:02 +0000 Subject: [PATCH 22/25] feat: add ConditionalOrderRemoved event and update natspec - Emit ConditionalOrderRemoved when orders are deauthorized via remove() - Update contract natspec to reflect ERC-1271 wallet support --- src/ComposableCoW.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ComposableCoW.sol b/src/ComposableCoW.sol index 989c333..71c05a8 100644 --- a/src/ComposableCoW.sol +++ b/src/ComposableCoW.sol @@ -17,7 +17,7 @@ import {CoWSettlement} from "./vendored/CoWSettlement.sol"; /// @title ComposableCoW - Conditional order framework for CoW Protocol /// @author mfw78 -/// @notice Enables Safe wallets to create conditional orders with dual-path verification. +/// @notice Enables ERC-1271 compatible wallets to create conditional orders with dual-path verification. /// @dev Settlement path (isValidSafeSignature) is gas-optimized; polling path returns rich metadata. contract ComposableCoW is ISafeSignatureVerifier { error ProofNotAuthed(); @@ -40,6 +40,7 @@ contract ComposableCoW is ISafeSignatureVerifier { event MerkleRootSet(address indexed owner, bytes32 root, Proof proof); event ConditionalOrderCreated(address indexed owner, IConditionalOrder.ConditionalOrderParams params); + event ConditionalOrderRemoved(address indexed owner, bytes32 indexed orderHash); event SwapGuardSet(address indexed owner, ISwapGuard swapGuard); CoWSettlement public immutable settlement; @@ -87,6 +88,7 @@ contract ComposableCoW is ISafeSignatureVerifier { function remove(bytes32 singleOrderHash) external { singleOrders[msg.sender][singleOrderHash] = false; cabinet[msg.sender][singleOrderHash] = bytes32(0); + emit ConditionalOrderRemoved(msg.sender, singleOrderHash); } function setSwapGuard(ISwapGuard swapGuard) external { From a58923445a812df310a04acb619b17227742e6ad Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 13 Feb 2026 10:09:13 +0000 Subject: [PATCH 23/25] test: add comprehensive manifest interface tests 27 tests covering: - BaseConditionalOrder default manifest behaviour - TWAP manifest with pagination and timing verification - StopLoss manifest with oracle condition checking - GoodAfterTime manifest with validFrom handling - TradeAboveThreshold manifest with threshold states - PerpetualStableSwap unbounded manifest - ConditionalOrderRemoved event emission --- test/ComposableCoW.manifest.t.sol | 601 ++++++++++++++++++++++++++++++ 1 file changed, 601 insertions(+) create mode 100644 test/ComposableCoW.manifest.t.sol diff --git a/test/ComposableCoW.manifest.t.sol b/test/ComposableCoW.manifest.t.sol new file mode 100644 index 0000000..d5cd68d --- /dev/null +++ b/test/ComposableCoW.manifest.t.sol @@ -0,0 +1,601 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { + IConditionalOrder, + GPv2Order, + ComposableCoW, + ComposableCoWLib, + Safe, + SafeLib, + BaseComposableCoWTest +} from "./ComposableCoW.base.t.sol"; + +import {IOrderManifest} from "../src/interfaces/IOrderManifest.sol"; +import {TWAP} from "../src/types/twap/TWAP.sol"; +import {TWAPOrder} from "../src/types/twap/libraries/TWAPOrder.sol"; +import {StopLoss} from "../src/types/StopLoss.sol"; +import {GoodAfterTime} from "../src/types/GoodAfterTime.sol"; +import {PerpetualStableSwap} from "../src/types/PerpetualStableSwap.sol"; +import {TradeAboveThreshold} from "../src/types/TradeAboveThreshold.sol"; +import {IConditionalOrderGenerator} from "../src/interfaces/IConditionalOrder.sol"; +import {IAggregatorV3Interface} from "../src/interfaces/IAggregatorV3Interface.sol"; +import {IERC20} from "cowprotocol/contracts/libraries/GPv2Order.sol"; +import {CurrentBlockTimestampFactory} from "../src/value_factories/CurrentBlockTimestampFactory.sol"; +import {IValueFactory} from "../src/interfaces/IValueFactory.sol"; + +/// @dev Mock oracle for testing +contract MockOracle { + int256 public price; + uint8 internal _decimals; + + constructor(int256 _price, uint8 decimals_) { + price = _price; + _decimals = decimals_; + } + + function decimals() external view returns (uint8) { + return _decimals; + } + + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + return (1, price, block.timestamp, block.timestamp, 1); + } +} + +contract ComposableCoWManifestTest is BaseComposableCoWTest { + using ComposableCoWLib for IConditionalOrder.ConditionalOrderParams[]; + using SafeLib for Safe; + + // Event for testing ConditionalOrderRemoved + event ConditionalOrderRemoved(address indexed owner, bytes32 indexed orderHash); + + PerpetualStableSwap perpetualSwap; + StopLoss stopLoss; + TradeAboveThreshold tradeAboveThreshold; + GoodAfterTime goodAfterTime; + IValueFactory currentBlockTimestampFactory; + + uint256 constant SELL_AMOUNT = 24000e18; + uint256 constant LIMIT_PRICE = 100e18; + uint32 constant FREQUENCY = 1 hours; + uint32 constant NUM_PARTS = 24; + uint32 constant SPAN = 5 minutes; + + function setUp() public virtual override(BaseComposableCoWTest) { + super.setUp(); + + // Deploy order type contracts + perpetualSwap = new PerpetualStableSwap(); + stopLoss = new StopLoss(); + tradeAboveThreshold = new TradeAboveThreshold(); + goodAfterTime = new GoodAfterTime(); + currentBlockTimestampFactory = new CurrentBlockTimestampFactory(); + } + + // ============ ConditionalOrderRemoved Event Tests ============ + + function test_remove_EmitsConditionalOrderRemoved() public { + // Create a simple order + IConditionalOrder.ConditionalOrderParams memory params = getPassthroughOrder(); + _create(address(safe1), params, false); + + bytes32 orderHash = keccak256(abi.encode(params)); + + // Expect the event to be emitted + vm.expectEmit(true, true, true, true); + emit ConditionalOrderRemoved(address(safe1), orderHash); + + // Remove the order + vm.prank(address(safe1)); + composableCow.remove(orderHash); + } + + function test_remove_FuzzEmitsEvent(address owner, bytes32 salt) public { + vm.assume(owner != address(0)); + + IConditionalOrder.ConditionalOrderParams memory params = + createOrder(passThrough, salt, bytes("")); + + // Create order directly (not through _create since owner may not be a Safe) + vm.prank(owner); + composableCow.create(params, false); + + bytes32 orderHash = keccak256(abi.encode(params)); + + vm.expectEmit(true, true, true, true); + emit ConditionalOrderRemoved(owner, orderHash); + + vm.prank(owner); + composableCow.remove(orderHash); + } + + // ============ TWAP Manifest Tests ============ + + function test_TWAP_getManifestInfo_ReturnsFiniteCardinality() public { + TWAPOrder.Data memory twapData = _twapTestBundle(block.timestamp); + + IOrderManifest.ManifestInfo memory info = twap.getManifestInfo( + address(safe1), bytes32(0), abi.encode(twapData) + ); + + assertEq(uint256(info.cardinality), uint256(IOrderManifest.Cardinality.FINITE)); + assertEq(info.totalOrders, NUM_PARTS); + } + + function test_TWAP_getManifestPage_ReturnsAllParts() public { + uint256 startTime = block.timestamp; + TWAPOrder.Data memory twapData = _twapTestBundle(startTime); + + // Get all parts in one page + (IOrderManifest.ManifestEntry[] memory entries, bool hasMore) = twap.getManifestPage( + address(safe1), bytes32(0), abi.encode(twapData), bytes(""), 0, NUM_PARTS + ); + + assertEq(entries.length, NUM_PARTS); + assertFalse(hasMore); + + // Verify each entry + for (uint256 i = 0; i < entries.length; i++) { + assertEq(entries[i].index, i); + assertEq(entries[i].validFrom, startTime + (i * FREQUENCY)); + + // Verify order parameters + assertEq(address(entries[i].order.sellToken), address(token0)); + assertEq(address(entries[i].order.buyToken), address(token1)); + assertEq(entries[i].order.sellAmount, twapData.partSellAmount); + assertEq(entries[i].order.buyAmount, twapData.minPartLimit); + } + } + + function test_TWAP_getManifestPage_Pagination() public { + TWAPOrder.Data memory twapData = _twapTestBundle(block.timestamp); + + // Get first 10 parts + (IOrderManifest.ManifestEntry[] memory page1, bool hasMore1) = twap.getManifestPage( + address(safe1), bytes32(0), abi.encode(twapData), bytes(""), 0, 10 + ); + + assertEq(page1.length, 10); + assertTrue(hasMore1); + assertEq(page1[0].index, 0); + assertEq(page1[9].index, 9); + + // Get next 10 parts + (IOrderManifest.ManifestEntry[] memory page2, bool hasMore2) = twap.getManifestPage( + address(safe1), bytes32(0), abi.encode(twapData), bytes(""), 10, 10 + ); + + assertEq(page2.length, 10); + assertTrue(hasMore2); + assertEq(page2[0].index, 10); + assertEq(page2[9].index, 19); + + // Get last 4 parts + (IOrderManifest.ManifestEntry[] memory page3, bool hasMore3) = twap.getManifestPage( + address(safe1), bytes32(0), abi.encode(twapData), bytes(""), 20, 10 + ); + + assertEq(page3.length, 4); + assertFalse(hasMore3); + assertEq(page3[0].index, 20); + assertEq(page3[3].index, 23); + } + + function test_TWAP_getManifestPage_UninitializedReturnsEmpty() public { + // Create TWAP data with t0=0 (needs context to initialize) + TWAPOrder.Data memory twapData = _twapTestBundle(0); + + // Without context being set, t0 remains 0 + (IOrderManifest.ManifestEntry[] memory entries, bool hasMore) = twap.getManifestPage( + address(safe1), bytes32(0), abi.encode(twapData), bytes(""), 0, 10 + ); + + assertEq(entries.length, 0); + assertFalse(hasMore); + } + + function test_TWAP_getManifestPage_WithContext() public { + TWAPOrder.Data memory twapData = _twapTestBundle(0); + uint256 contextTime = block.timestamp + 1 days; + + // Create order with context + IConditionalOrder.ConditionalOrderParams memory params = + createOrder(twap, keccak256("twap"), abi.encode(twapData)); + + // Create with context + vm.warp(contextTime); + _createWithContext(address(safe1), params, currentBlockTimestampFactory, bytes(""), false); + + bytes32 ctx = composableCow.hash(params); + + // Get manifest using the context + (IOrderManifest.ManifestEntry[] memory entries, bool hasMore) = twap.getManifestPage( + address(safe1), ctx, abi.encode(twapData), bytes(""), 0, 5 + ); + + assertEq(entries.length, 5); + assertTrue(hasMore); + // First entry should have validFrom = contextTime (what was stored in cabinet) + assertEq(entries[0].validFrom, contextTime); + } + + function test_TWAP_ManifestEntriesMatchGenerateOrder() public { + uint256 startTime = block.timestamp; + TWAPOrder.Data memory twapData = _twapTestBundle(startTime); + + // Get all manifest entries + (IOrderManifest.ManifestEntry[] memory entries,) = twap.getManifestPage( + address(safe1), bytes32(0), abi.encode(twapData), bytes(""), 0, NUM_PARTS + ); + + // For each part, warp to its validFrom and verify generateOrder matches + for (uint256 i = 0; i < entries.length; i++) { + vm.warp(entries[i].validFrom); + + GPv2Order.Data memory generatedOrder = twap.generateOrder( + address(safe1), address(0), bytes32(0), abi.encode(twapData), bytes("") + ); + + // Compare key fields + assertEq(address(entries[i].order.sellToken), address(generatedOrder.sellToken)); + assertEq(address(entries[i].order.buyToken), address(generatedOrder.buyToken)); + assertEq(entries[i].order.sellAmount, generatedOrder.sellAmount); + assertEq(entries[i].order.buyAmount, generatedOrder.buyAmount); + assertEq(entries[i].order.validTo, generatedOrder.validTo); + } + } + + function test_TWAP_IsActive_DuringSpan() public { + uint256 startTime = block.timestamp; + TWAPOrder.Data memory twapData = _twapTestBundle(startTime); + + // Warp to middle of first part's span + vm.warp(startTime + SPAN / 2); + + (IOrderManifest.ManifestEntry[] memory entries,) = twap.getManifestPage( + address(safe1), bytes32(0), abi.encode(twapData), bytes(""), 0, 3 + ); + + assertTrue(entries[0].isActive); // First part is active + assertFalse(entries[1].isActive); // Second part not yet active + assertFalse(entries[2].isActive); // Third part not yet active + } + + // ============ Single-Shot Order Manifest Tests ============ + + function test_BaseConditionalOrder_DefaultManifest_ReturnsFiniteOne() public { + // passThrough inherits from BaseConditionalOrder which has default manifest + IOrderManifest.ManifestInfo memory info = passThrough.getManifestInfo( + address(safe1), bytes32(0), bytes("") + ); + + assertEq(uint256(info.cardinality), uint256(IOrderManifest.Cardinality.FINITE)); + assertEq(info.totalOrders, 1); + } + + function test_BaseConditionalOrder_DefaultManifestPage() public { + GPv2Order.Data memory order = getBlankOrder(); + + (IOrderManifest.ManifestEntry[] memory entries, bool hasMore) = passThrough.getManifestPage( + address(safe1), bytes32(0), bytes(""), abi.encode(order), 0, 10 + ); + + assertEq(entries.length, 1); + assertFalse(hasMore); + assertEq(entries[0].index, 0); + assertEq(entries[0].validFrom, 0); + } + + function test_BaseConditionalOrder_ManifestPage_OffsetSkipsOrder() public { + GPv2Order.Data memory order = getBlankOrder(); + + // Offset > 0 should return empty for single-shot orders + (IOrderManifest.ManifestEntry[] memory entries, bool hasMore) = passThrough.getManifestPage( + address(safe1), bytes32(0), bytes(""), abi.encode(order), 1, 10 + ); + + assertEq(entries.length, 0); + assertFalse(hasMore); + } + + // ============ PerpetualStableSwap Manifest Tests ============ + + function test_PerpetualStableSwap_ManifestReturnsUnbounded() public { + PerpetualStableSwap.Data memory data = PerpetualStableSwap.Data({ + tokenA: token0, + tokenB: token1, + validityBucketSeconds: 300, + halfSpreadBps: 50, + appData: keccak256("perpetual") + }); + + IOrderManifest.ManifestInfo memory info = perpetualSwap.getManifestInfo( + address(safe1), bytes32(0), abi.encode(data) + ); + + assertEq(uint256(info.cardinality), uint256(IOrderManifest.Cardinality.UNBOUNDED)); + assertEq(info.totalOrders, 0); + } + + function test_PerpetualStableSwap_HasMoreAlwaysTrue() public { + PerpetualStableSwap.Data memory data = PerpetualStableSwap.Data({ + tokenA: token0, + tokenB: token1, + validityBucketSeconds: 300, + halfSpreadBps: 50, + appData: keccak256("perpetual") + }); + + // Fund the safe + deal(address(token0), address(safe1), 1000e18); + + (, bool hasMore) = perpetualSwap.getManifestPage( + address(safe1), bytes32(0), abi.encode(data), bytes(""), 0, 10 + ); + + // Perpetual orders always have more + assertTrue(hasMore); + } + + function test_PerpetualStableSwap_ManifestPage_NotFunded() public { + // Use a fresh address with no token balances + address unfundedOwner = makeAddr("unfunded"); + + PerpetualStableSwap.Data memory data = PerpetualStableSwap.Data({ + tokenA: token0, + tokenB: token1, + validityBucketSeconds: 300, + halfSpreadBps: 50, + appData: keccak256("perpetual") + }); + + // Don't fund the owner - order should fail to generate (both balances are 0) + (IOrderManifest.ManifestEntry[] memory entries, bool hasMore) = perpetualSwap.getManifestPage( + unfundedOwner, bytes32(0), abi.encode(data), bytes(""), 0, 10 + ); + + // Empty entries but still hasMore (perpetual) + assertEq(entries.length, 0); + assertTrue(hasMore); + } + + function test_PerpetualStableSwap_ManifestPage_WithBalance() public { + PerpetualStableSwap.Data memory data = PerpetualStableSwap.Data({ + tokenA: token0, + tokenB: token1, + validityBucketSeconds: 300, + halfSpreadBps: 50, + appData: keccak256("perpetual") + }); + + // Fund the safe with tokenA + deal(address(token0), address(safe1), 1000e18); + + (IOrderManifest.ManifestEntry[] memory entries, bool hasMore) = perpetualSwap.getManifestPage( + address(safe1), bytes32(0), abi.encode(data), bytes(""), 0, 10 + ); + + assertEq(entries.length, 1); + assertTrue(hasMore); + assertEq(entries[0].index, 0); + assertEq(address(entries[0].order.sellToken), address(token0)); + assertEq(entries[0].order.sellAmount, 1000e18); + } + + // ============ StopLoss Manifest Tests ============ + + function test_StopLoss_ManifestShowsOrderStructure() public { + // Warp to a reasonable timestamp to avoid underflow in staleness check + vm.warp(1700000000); + + // Create stop loss data - the manifest should show order structure + // even if oracle conditions aren't met + StopLoss.Data memory data = StopLoss.Data({ + sellToken: token0, + buyToken: token1, + sellAmount: 1000e18, + buyAmount: 900e18, + appData: keccak256("stoploss"), + receiver: address(safe1), + isSellOrder: true, + isPartiallyFillable: false, + validTo: uint32(block.timestamp + 1 days), + sellTokenPriceOracle: IAggregatorV3Interface(address(new MockOracle(1e8, 18))), + buyTokenPriceOracle: IAggregatorV3Interface(address(new MockOracle(1e8, 18))), + strike: 0, // Strike of 0 means price ratio must be <= 0 (never true for positive prices) + maxTimeSinceLastOracleUpdate: 1 hours + }); + + // Should return the order structure with isActive=false (strike not reached) + (IOrderManifest.ManifestEntry[] memory entries, bool hasMore) = stopLoss.getManifestPage( + address(safe1), bytes32(0), abi.encode(data), bytes(""), 0, 10 + ); + + assertEq(entries.length, 1); + assertFalse(hasMore); + assertEq(entries[0].index, 0); + assertEq(entries[0].validFrom, 0); // Condition-based + assertFalse(entries[0].isActive); // Strike not reached + assertEq(entries[0].order.sellAmount, 1000e18); + assertEq(entries[0].order.buyAmount, 900e18); + } + + function test_StopLoss_ManifestReturnsEmptyWhenExpired() public { + // Create expired stop loss data + StopLoss.Data memory data = StopLoss.Data({ + sellToken: token0, + buyToken: token1, + sellAmount: 1000e18, + buyAmount: 900e18, + appData: keccak256("stoploss"), + receiver: address(0), + isSellOrder: true, + isPartiallyFillable: false, + validTo: uint32(block.timestamp - 1), // Already expired + sellTokenPriceOracle: IAggregatorV3Interface(address(0)), + buyTokenPriceOracle: IAggregatorV3Interface(address(0)), + strike: 1e18, + maxTimeSinceLastOracleUpdate: 1 hours + }); + + (IOrderManifest.ManifestEntry[] memory entries, bool hasMore) = stopLoss.getManifestPage( + address(safe1), bytes32(0), abi.encode(data), bytes(""), 0, 10 + ); + + assertEq(entries.length, 0); + assertFalse(hasMore); + } + + // ============ GoodAfterTime Manifest Tests ============ + + function test_GoodAfterTime_ManifestSetsValidFromToStartTime() public { + uint256 startTime = block.timestamp + 1 hours; + uint256 endTime = block.timestamp + 2 hours; + + GoodAfterTime.Data memory data = GoodAfterTime.Data({ + sellToken: token0, + buyToken: token1, + receiver: address(0), + sellAmount: 1000e18, + minSellBalance: 500e18, + startTime: startTime, + endTime: endTime, + allowPartialFill: false, + priceCheckerPayload: bytes(""), + appData: keccak256("gat") + }); + + // Fund the safe + deal(address(token0), address(safe1), 1000e18); + + // Warp to after startTime + vm.warp(startTime + 1); + + // Get manifest with offchainInput for buyAmount + (IOrderManifest.ManifestEntry[] memory entries, bool hasMore) = goodAfterTime.getManifestPage( + address(safe1), bytes32(0), abi.encode(data), abi.encode(uint256(800e18)), 0, 10 + ); + + assertEq(entries.length, 1); + assertFalse(hasMore); + assertEq(entries[0].validFrom, startTime); + assertTrue(entries[0].isActive); + } + + function test_GoodAfterTime_ManifestShowsInactiveBeforeStartTime() public { + uint256 startTime = block.timestamp + 1 hours; + uint256 endTime = block.timestamp + 2 hours; + + GoodAfterTime.Data memory data = GoodAfterTime.Data({ + sellToken: token0, + buyToken: token1, + receiver: address(0), + sellAmount: 1000e18, + minSellBalance: 500e18, + startTime: startTime, + endTime: endTime, + allowPartialFill: false, + priceCheckerPayload: bytes(""), + appData: keccak256("gat") + }); + + // Fund the safe + deal(address(token0), address(safe1), 1000e18); + + // Don't warp - still before startTime + // generateOrder will revert with PollTryAtTimestamp, so manifest returns empty + (IOrderManifest.ManifestEntry[] memory entries,) = goodAfterTime.getManifestPage( + address(safe1), bytes32(0), abi.encode(data), abi.encode(uint256(800e18)), 0, 10 + ); + + // Returns empty because generateOrder reverts before startTime + assertEq(entries.length, 0); + } + + // ============ TradeAboveThreshold Manifest Tests ============ + + function test_TradeAboveThreshold_ManifestShowsOrderWhenBelowThreshold() public { + TradeAboveThreshold.Data memory data = TradeAboveThreshold.Data({ + sellToken: token0, + buyToken: token1, + receiver: address(0), + validityBucketSeconds: 300, + threshold: 1000e18, + appData: keccak256("tat") + }); + + // Fund safe with less than threshold + deal(address(token0), address(safe1), 500e18); + + (IOrderManifest.ManifestEntry[] memory entries, bool hasMore) = tradeAboveThreshold.getManifestPage( + address(safe1), bytes32(0), abi.encode(data), bytes(""), 0, 10 + ); + + assertEq(entries.length, 1); + assertFalse(hasMore); + assertEq(entries[0].validFrom, 0); // Condition-based + assertFalse(entries[0].isActive); // Below threshold + // Shows threshold as sellAmount when below + assertEq(entries[0].order.sellAmount, 1000e18); + } + + function test_TradeAboveThreshold_ManifestShowsActiveWhenAboveThreshold() public { + TradeAboveThreshold.Data memory data = TradeAboveThreshold.Data({ + sellToken: token0, + buyToken: token1, + receiver: address(0), + validityBucketSeconds: 300, + threshold: 1000e18, + appData: keccak256("tat") + }); + + // Fund safe with more than threshold + deal(address(token0), address(safe1), 1500e18); + + (IOrderManifest.ManifestEntry[] memory entries, bool hasMore) = tradeAboveThreshold.getManifestPage( + address(safe1), bytes32(0), abi.encode(data), bytes(""), 0, 10 + ); + + assertEq(entries.length, 1); + assertFalse(hasMore); + assertTrue(entries[0].isActive); // Above threshold + // Shows actual balance as sellAmount when above + assertEq(entries[0].order.sellAmount, 1500e18); + } + + // ============ Interface Support Tests ============ + + function test_TWAP_SupportsIOrderManifest() public { + assertTrue(twap.supportsInterface(type(IOrderManifest).interfaceId)); + } + + function test_TWAP_SupportsIConditionalOrderGenerator() public { + assertTrue(twap.supportsInterface(type(IConditionalOrderGenerator).interfaceId)); + } + + function test_BaseConditionalOrder_SupportsIOrderManifest() public { + assertTrue(passThrough.supportsInterface(type(IOrderManifest).interfaceId)); + } + + // ============ Helper Functions ============ + + function _twapTestBundle(uint256 startTime) internal view returns (TWAPOrder.Data memory) { + return TWAPOrder.Data({ + sellToken: token0, + buyToken: token1, + receiver: address(0), + partSellAmount: SELL_AMOUNT / NUM_PARTS, + minPartLimit: LIMIT_PRICE, + t0: startTime, + n: NUM_PARTS, + t: FREQUENCY, + span: SPAN, + appData: keccak256("test.twap") + }); + } +} From 97c0a5ea996b255c6507db31d6a9dc78f9e40427 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 13 Feb 2026 10:09:43 +0000 Subject: [PATCH 24/25] docs: update architecture for ERC-1271 support and add breaking changes Updates architecture documentation to reflect: - ERC-1271 wallet support (not just Safe wallets) - ERC1271Forwarder integration pattern - IOrderManifest interface and implementation details - ConditionalOrderRemoved event Adds comprehensive breaking changes section documenting all interface and contract changes from cowprotocol/composable-cow upstream: - IConditionalOrder: PollTryAtEpoch renamed, PollNever removed - IConditionalOrderGenerator: getTradeableOrder() replaced by poll() - ComposableCoW: return type and fill status changes - BaseConditionalOrder: manifest support added --- docs/architecture.md | 294 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 259 insertions(+), 35 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 350bbc5..da70952 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,21 +2,23 @@ ## Overview -Composable CoW is a framework for creating conditional orders on CoW Protocol. It enables Safe wallets to define orders that become tradeable when specific conditions are met (price thresholds, time windows, balance triggers, etc.). +Composable CoW is a framework for creating conditional orders on CoW Protocol. It enables any wallet capable of ERC-1271 signatures to define orders that become tradeable when specific conditions are met (price thresholds, time windows, balance triggers, and so forth). + +ERC-1271 is the standard for smart contract signature verification, allowing contracts to validate signatures on behalf of their owners. This includes Safe wallets, Argent, Sequence, and other smart contract wallets. The architecture separates two distinct execution paths: -1. **Settlement Path** - On-chain verification during trade execution (gas-sensitive) -2. **Polling Path** - Off-chain queries by watch-towers (gas-irrelevant) +1. **Settlement Path** — on-chain verification during trade execution (gas-sensitive). +2. **Polling Path** — off-chain queries by watch-towers (gas-irrelevant). This separation ensures settlement remains gas-efficient while providing rich metadata for off-chain infrastructure. ## Design Principles -1. **Single source of truth**: `generateOrder()` contains all order generation logic -2. **Lean settlement**: No metadata structs, only constant string errors for debugging -3. **Rich polling**: Structured results with scheduling hints for watch-towers -4. **No code duplication**: Polling wraps the same core logic used by settlement +1. **Single source of truth**: `generateOrder()` contains all order generation logic. +2. **Lean settlement**: No metadata structs; only constant string errors for debugging. +3. **Rich polling**: Structured results with scheduling hints for watch-towers. +4. **No code duplication**: Polling wraps the same core logic used by settlement. ## Interface Hierarchy @@ -37,6 +39,13 @@ IConditionalOrderGenerator : IConditionalOrder, IERC165 ├── poll() - rich polling with metadata ├── getNextPollTimestamp() - scheduling hints └── describeOrder() - human-readable status + +IOrderManifest +├── Cardinality enum (FINITE, BOUNDED, UNBOUNDED) +├── ManifestInfo struct (cardinality, totalOrders) +├── ManifestEntry struct (index, order, validFrom, isActive) +├── getManifestInfo() - order cardinality info +└── getManifestPage() - paginated order enumeration ``` ## Execution Paths @@ -47,13 +56,13 @@ IConditionalOrderGenerator : IConditionalOrder, IERC165 CoW Settlement │ ▼ -Safe.isValidSignature(hash, signature) +Wallet.isValidSignature(hash, signature) ERC-1271 verification │ ▼ -ExtensibleFallbackHandler +[Wallet-specific routing] e.g., Safe's ExtensibleFallbackHandler │ ▼ -ComposableCoW.isValidSafeSignature(...) +ComposableCoW.isValidSafeSignature(...) Signature validation │ ├── _auth() Verify merkle proof or single order ├── _guardCheck() Optional swap guard @@ -66,11 +75,13 @@ ComposableCoW.isValidSafeSignature(...) └── hash check Verify order matches ``` +**Note**: The function `isValidSafeSignature` works with any ERC-1271-compatible wallet that routes signature verification to ComposableCoW. The name reflects the original Safe integration, but the interface is wallet-agnostic. + **Gas considerations**: -- Error reasons use constant strings (minimal allocation) -- No PollResult construction -- No polling metadata calls -- Minimal computation beyond core validation +- Error reasons use constant strings (minimal allocation). +- No `PollResult` construction. +- No polling metadata calls. +- Minimal computation beyond core validation. ### Polling Path (Off-Chain) @@ -111,14 +122,14 @@ _getFilledAmount() Check GPv2Settlement ``` **Characteristics**: -- Returns structured `PollResult`, never reverts for order conditions -- Includes scheduling hints (`nextPollTimestamp`, `waitUntil`) -- Human-readable reasons for debugging -- Checks GPv2Settlement for fill status before building signature +- Returns structured `PollResult`; never reverts for order conditions. +- Includes scheduling hints (`nextPollTimestamp` and `waitUntil`). +- Provides human-readable reasons for debugging. +- Checks GPv2Settlement for fill status before building the signature. ## Error Types -Errors include string reasons for debugging. Using constant strings (e.g., `INVALID_HASH`, `BEFORE_TWAP_START`) minimizes gas overhead while improving debuggability: +Errors include string reasons for debugging. Using constant strings (e.g., `INVALID_HASH` and `BEFORE_TWAP_START`) minimizes gas overhead while improving debuggability: | Error | Meaning | Watch-tower Action | |-------|---------|-------------------| @@ -155,8 +166,8 @@ struct PollResult { | Code | Meaning | Watch-tower Action | |------|---------|-------------------| | `SUCCESS` | Order ready to trade | Submit to CoW Protocol API | -| `PARTIALLY_FILLED` | Order partially filled | No action, informational only | -| `FILLED` | Order completely filled | No action, informational only | +| `PARTIALLY_FILLED` | Order partially filled | No action; informational only | +| `FILLED` | Order completely filled | No action; informational only | | `WAIT_TIMESTAMP` | Wait for specific time | Schedule poll at `waitUntil` | | `WAIT_BLOCK` | Wait for specific block | Schedule poll at block `waitUntil` | | `TRY_NEXT_BLOCK` | Transient condition | Poll again next block | @@ -220,8 +231,67 @@ function getNextPollTimestamp(...) external pure returns (uint256) { } ``` +## Order Manifest Interface + +The `IOrderManifest` interface enables enumeration of all discrete orders that a conditional order will produce. This is useful for analytics, UI previews, and order lifecycle tracking. + +### Cardinality Types + +| Cardinality | Description | Example | +|-------------|-------------|---------| +| `FINITE` | Known fixed number of orders | TWAP with n parts | +| `BOUNDED` | Upper bound known; actual count is dynamic | Future order types | +| `UNBOUNDED` | Potentially infinite orders | PerpetualStableSwap | + +### ManifestInfo Structure + +```solidity +struct ManifestInfo { + Cardinality cardinality; + uint256 totalOrders; // Exact for FINITE, max for BOUNDED, 0 for UNBOUNDED +} +``` + +### ManifestEntry Structure + +```solidity +struct ManifestEntry { + uint256 index; // Order index (0-indexed) + GPv2Order.Data order; // The discrete order + uint256 validFrom; // When this order becomes valid + bool isActive; // Whether currently within validity window +} +``` + +The `validFrom` field is needed because `GPv2Order.Data` only contains `validTo`. + +### Manifest Implementation by Order Type + +| Order Type | Cardinality | totalOrders | Behavior | +|------------|-------------|-------------|----------| +| TWAP | FINITE | n (number of parts) | Returns all n parts with timing | +| StopLoss | FINITE | 1 | Single order from generateOrder() | +| GoodAfterTime | FINITE | 1 | Single order from generateOrder() | +| TradeAboveThreshold | FINITE | 1 | Single order from generateOrder() | +| PerpetualStableSwap | UNBOUNDED | 0 | Current order, hasMore=true | + +### Default Implementation + +`BaseConditionalOrder` provides a default manifest implementation for single-shot orders: +- `getManifestInfo()` returns `FINITE` with `totalOrders: 1`. +- `getManifestPage()` wraps `generateOrder()` for a single entry. + ## ComposableCoW Contract +### Events + +| Event | Description | +|-------|-------------| +| `MerkleRootSet(address indexed owner, bytes32 root, Proof proof)` | Merkle root updated | +| `ConditionalOrderCreated(address indexed owner, ConditionalOrderParams params)` | Order created with dispatch=true | +| `ConditionalOrderRemoved(address indexed owner, bytes32 indexed orderHash)` | Order deauthorized | +| `SwapGuardSet(address indexed owner, ISwapGuard swapGuard)` | Swap guard updated | + ### Key Functions | Function | Path | Returns | @@ -236,9 +306,9 @@ Orders are authorized via: - **Single orders**: `singleOrders[owner][hash(params)] = true` - **Merkle roots**: `roots[owner] = merkleRoot` -The `_auth()` function verifies authorization and returns the context key: -- Merkle orders: `ctx = bytes32(0)` -- Single orders: `ctx = hash(params)` +The `_auth()` function verifies authorization and returns the context key as follows: +- Merkle orders: `ctx = bytes32(0)`. +- Single orders: `ctx = hash(params)`. ### Context Storage (Cabinet) @@ -247,20 +317,68 @@ The `cabinet` mapping stores per-order context: mapping(address owner => mapping(bytes32 ctx => bytes32 value)) public cabinet; ``` -Used by TWAP to store dynamic start times set at order creation. +This is used by TWAP to store dynamic start times set at order creation. + +## ERC-1271 Integration + +ComposableCoW is designed to work with any smart contract wallet that implements ERC-1271 (`isValidSignature`). The integration requires the wallet to route signature verification requests to ComposableCoW. + +### How It Works + +1. **Order Creation**: The wallet owner authorizes conditional orders via `create()` or `setRoot()`. +2. **Signature Verification**: When CoW Protocol settlement calls `isValidSignature(hash, signature)` on the wallet, it routes the call to ComposableCoW. +3. **Validation**: ComposableCoW verifies authorization and validates the order via `generateOrder()`. + +### Supported Wallets + +| Wallet Type | Integration Method | +|-------------|-------------------| +| Safe | ExtensibleFallbackHandler with domain verifier | +| Other ERC-1271 | Extend `ERC1271Forwarder` abstract contract | + +### ERC1271Forwarder + +The `ERC1271Forwarder` abstract contract provides a ready-made integration for any ERC-1271 wallet. Extend this contract to add ComposableCoW support: + +```solidity +import {ERC1271Forwarder} from "./ERC1271Forwarder.sol"; + +contract MyWallet is ERC1271Forwarder { + constructor(ComposableCoW _composableCoW) ERC1271Forwarder(_composableCoW) {} + // ... wallet implementation +} +``` + +The forwarder: +1. Receives `isValidSignature(bytes32 _hash, bytes signature)` calls. +2. Decodes the signature as `(GPv2Order.Data, ComposableCoW.PayloadStruct)`. +3. Verifies that the order hash matches the provided hash. +4. Forwards the request to `ComposableCoW.isValidSafeSignature()` for order validation. + +### Custom Integration + +For wallets that cannot extend `ERC1271Forwarder`, implement the forwarding manually: + +1. Decode the signature to extract `GPv2Order.Data` and `ComposableCoW.PayloadStruct`. +2. Verify that `GPv2Order.hash(order, domainSeparator) == _hash`. +3. Call `composableCoW.isValidSafeSignature(owner, sender, hash, domainSeparator, typeHash, encodedOrder, encodedPayload)`. ## Implementation Checklist for New Order Types -1. Extend `BaseConditionalOrder` +1. Extend `BaseConditionalOrder`. 2. Implement `generateOrder()`: - - Validate conditions using `require(condition, CustomError(reason))` - - Use constant string reasons (e.g., `string constant MY_ERROR = "my error"`) - - Build and return `GPv2Order.Data` -3. Override `getNextPollTimestamp()` if not using default: - - Return `0` for "use validTo + 1" - - Return `type(uint256).max` for single-shot orders - - Return specific timestamp for multi-part orders -4. Optionally override `describeOrder()` for better UX + - Validate conditions using `require(condition, CustomError(reason))`. + - Use constant string reasons (e.g., `string constant MY_ERROR = "my error"`). + - Build and return `GPv2Order.Data`. +3. Override `getNextPollTimestamp()` if not using the default: + - Return `0` for 'use validTo + 1'. + - Return `type(uint256).max` for single-shot orders. + - Return a specific timestamp for multi-part orders. +4. Optionally override `describeOrder()` for better UX. +5. Override manifest functions if not single-shot: + - `getManifestInfo()` — return appropriate cardinality. + - `getManifestPage()` — implement pagination for multi-part orders. + - For UNBOUNDED orders, always return `hasMore=true`. ## Gas Comparison @@ -273,4 +391,110 @@ Used by TWAP to store dynamic start times set at order creation. | Error reason strings | Yes (constants) | Yes (constants) | | PollResult construction | No | Yes | -The settlement path only executes what's necessary for validation. Error reason strings use compile-time constants to minimize gas overhead while providing useful debugging information. +The settlement path executes only what is necessary for validation. Error reason strings use compile-time constants to minimize gas overhead while providing useful debugging information. + +## Breaking Changes from Upstream + +This fork introduces significant architectural changes from [cowprotocol/composable-cow](https://github.com/cowprotocol/composable-cow). The following sections document all breaking changes for migration purposes. + +### IConditionalOrder Interface + +| Change | Upstream | This Fork | +|--------|----------|-----------| +| Error renamed | `PollTryAtEpoch(uint256, string)` | `PollTryAtTimestamp(uint256, string)` | +| Error removed | `PollNever(string)` | Use `PollResultCode.INVALID` instead | +| Function added | - | `generateOrder()` (moved from IConditionalOrderGenerator) | + +**Migration**: Replace `PollTryAtEpoch` with `PollTryAtTimestamp`, and replace `revert PollNever(reason)` with `revert OrderNotValid(reason)`. + +### IConditionalOrderGenerator Interface + +| Change | Upstream | This Fork | +|--------|----------|-----------| +| Function removed | `getTradeableOrder()` | Use `generateOrder()` (in base interface) | +| Struct added | - | `PollResult` | +| Enum added | - | `PollResultCode` | +| Function added | - | `poll()` returning `PollResult` | +| Function added | - | `getNextPollTimestamp()` | +| Function added | - | `describeOrder()` | + +**Migration**: Rename `getTradeableOrder()` to `generateOrder()`, and implement `getNextPollTimestamp()` and `describeOrder()` (or use the defaults from `BaseConditionalOrder`). + +### ComposableCoW Contract + +| Change | Upstream | This Fork | +|--------|----------|-----------| +| Return type changed | `getTradeableOrderWithSignature() returns (GPv2Order.Data, bytes)` | `getTradeableOrderWithSignature() returns (PollResult, bytes)` | +| Function added | - | `checkOrder() returns (PollResultCode, uint256)` | +| Event added | - | `ConditionalOrderRemoved(address indexed, bytes32 indexed)` | +| State added | - | `settlement` (CoWSettlement immutable) | +| Feature added | - | Fill status checking via `GPv2Settlement.filledAmount()` | + +**Migration**: Update callers of `getTradeableOrderWithSignature()` to handle the `PollResult` struct instead of the raw `GPv2Order.Data`. The order is now in `result.order`, and `result.code` indicates the status. + +```solidity +// Upstream +(GPv2Order.Data memory order, bytes memory sig) = composableCow.getTradeableOrderWithSignature(...); + +// This fork +(IConditionalOrderGenerator.PollResult memory result, bytes memory sig) = composableCow.getTradeableOrderWithSignature(...); +if (result.code == IConditionalOrderGenerator.PollResultCode.SUCCESS) { + GPv2Order.Data memory order = result.order; + // ... submit order +} +``` + +### BaseConditionalOrder + +| Change | Upstream | This Fork | +|--------|----------|-----------| +| Function renamed | `getTradeableOrder()` (abstract) | `generateOrder()` (abstract) | +| Function added | - | `poll()` (concrete implementation) | +| Function added | - | `getNextPollTimestamp()` (virtual, default: 0) | +| Function added | - | `describeOrder()` (virtual, default: "order ready") | +| Interface added | - | Implements `IOrderManifest` | +| Function added | - | `getManifestInfo()` (virtual, default: FINITE/1) | +| Function added | - | `getManifestPage()` (virtual, default: single entry) | +| Constant added | - | `POLL_AT_VALIDTO = 0` | +| Constant added | - | `POLL_NEVER = type(uint256).max` | + +**Migration**: Rename `getTradeableOrder()` to `generateOrder()`. The base class now provides a `poll()` implementation that wraps `generateOrder()` with try/catch. + +### New Interface: IOrderManifest + +This interface is entirely new and provides order enumeration capabilities: + +```solidity +interface IOrderManifest { + enum Cardinality { FINITE, BOUNDED, UNBOUNDED } + struct ManifestInfo { Cardinality cardinality; uint256 totalOrders; } + struct ManifestEntry { uint256 index; GPv2Order.Data order; uint256 validFrom; bool isActive; } + + function getManifestInfo(...) external view returns (ManifestInfo memory); + function getManifestPage(...) external view returns (ManifestEntry[] memory, bool hasMore); +} +``` + +**Migration**: No action is required for existing order types if extending `BaseConditionalOrder` (which provides a default single-shot implementation). Override for multi-part orders such as TWAP. + +### Vendored CoWSettlement Interface + +| Change | Upstream | This Fork | +|--------|----------|-----------| +| Function added | - | `filledAmount(bytes orderUid) returns (uint256)` | + +This addition enables the framework to check fill status and return `PARTIALLY_FILLED` or `FILLED` poll result codes accordingly. + +### Summary of Function Renames + +| Upstream | This Fork | +|----------|-----------| +| `getTradeableOrder()` | `generateOrder()` | +| `PollTryAtEpoch` | `PollTryAtTimestamp` | + +### Summary of Removed Items + +| Item | Replacement | +|------|-------------| +| `PollNever` error | `OrderNotValid` error or `PollResultCode.INVALID` | +| `GPv2Interaction` import | Removed (unused) | From c4ea3cc3ff8d5f5202b213241970d18405729e35 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 13 Feb 2026 10:10:03 +0000 Subject: [PATCH 25/25] chore: add foundry.lock for reproducible builds --- foundry.lock | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 foundry.lock diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..5d1e343 --- /dev/null +++ b/foundry.lock @@ -0,0 +1,26 @@ +{ + "lib/@openzeppelin": { + "rev": "fd81a96f01cc42ef1c9a5399364968d0e07e9e90" + }, + "lib/cowprotocol": { + "branch": { + "name": "main", + "rev": "a10f40788af29467e87de3dbf2196662b0a6b500" + } + }, + "lib/forge-std": { + "branch": { + "name": "v1.5.3", + "rev": "73a504d2cf6f37b7ce285b479f4c681f76e95f1b" + } + }, + "lib/murky": { + "rev": "1d9566b908b9702c45d354a1caabe8ef5a69938d" + }, + "lib/safe": { + "branch": { + "name": "main", + "rev": "11273c1f08eda18ed8ff49ec1d4abec5e451ff21" + } + } +} \ No newline at end of file