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 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..da70952 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,500 @@ +# Composable CoW Architecture + +## Overview + +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). + +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 + +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 + +### Settlement Path (On-Chain) + +``` +CoW Settlement + │ + ▼ +Wallet.isValidSignature(hash, signature) ERC-1271 verification + │ + ▼ +[Wallet-specific routing] e.g., Safe's ExtensibleFallbackHandler + │ + ▼ +ComposableCoW.isValidSafeSignature(...) Signature validation + │ + ├── _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 +``` + +**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. + +### 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` 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` and `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 +} +``` + +## 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 | +|----------|------|---------| +| `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 as follows: +- 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; +``` + +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`. +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 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 + +| 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 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) | 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 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 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; diff --git a/src/BaseConditionalOrder.sol b/src/BaseConditionalOrder.sol index 81a10ab..a050696 100644 --- a/src/BaseConditionalOrder.sol +++ b/src/BaseConditionalOrder.sol @@ -4,23 +4,21 @@ 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"; -// --- 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 - */ -abstract contract BaseConditionalOrder is IConditionalOrderGenerator { - /** - * @inheritdoc IConditionalOrder - * @dev As an order generator, the `GPv2Order.Data` passed as a parameter is ignored / not validated. - */ +/// @title BaseConditionalOrder - Base implementation for conditional orders +/// @author mfw78 +/// @notice Provides dual-path support: lean verify() for settlement, rich poll() for watch-towers +/// @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 + uint256 internal constant POLL_NEVER = type(uint256).max; + + /// @inheritdoc IConditionalOrder function verify( address owner, address sender, @@ -31,30 +29,235 @@ 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); + + // ============ 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 - */ + /// @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 + 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) + }); } } diff --git a/src/ComposableCoW.sol b/src/ComposableCoW.sol index 6aaa8b6..71c05a8 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 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 { - // --- errors error ProofNotAuthed(); error SingleOrderNotAuthed(); error SwapGuardRestricted(); @@ -29,104 +27,54 @@ 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 ConditionalOrderRemoved(address indexed owner, bytes32 indexed orderHash); 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 +82,159 @@ 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); + emit ConditionalOrderRemoved(msg.sender, singleOrderHash); } - /** - * 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 +248,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); + } } 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); } 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); +} 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)); diff --git a/src/types/GoodAfterTime.sol b/src/types/GoodAfterTime.sol index abae962..7c55865 100644 --- a/src/types/GoodAfterTime.sol +++ b/src/types/GoodAfterTime.sol @@ -4,41 +4,34 @@ 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 {IOrderManifest} from "../interfaces/IOrderManifest.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 +40,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 +79,68 @@ 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"; + } + + // ============ 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 c20076a..8156fff 100644 --- a/src/types/PerpetualStableSwap.sol +++ b/src/types/PerpetualStableSwap.sol @@ -8,29 +8,16 @@ import { IConditionalOrderGenerator, BaseConditionalOrder } from "../BaseConditionalOrder.sol"; +import {IOrderManifest} from "../interfaces/IOrderManifest.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 +30,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), @@ -83,11 +59,65 @@ contract PerpetualStableSwap is BaseConditionalOrder { ); } - function side(address owner, PerpetualStableSwap.Data memory data) - internal - view - returns (BuySellData memory buySellData) + /// @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)); IERC20 tokenB = IERC20(address(data.tokenB)); uint256 balanceA = tokenA.balanceOf(owner); @@ -98,14 +128,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 }); } } diff --git a/src/types/StopLoss.sol b/src/types/StopLoss.sol index 2ac6e0f..e34fc7e 100644 --- a/src/types/StopLoss.sol +++ b/src/types/StopLoss.sol @@ -1,47 +1,29 @@ // 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 {IOrderManifest} from "../interfaces/IOrderManifest.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 +40,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 +80,124 @@ 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"; + } + + // ============ 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 df815e5..3087631 100644 --- a/src/types/TradeAboveThreshold.sol +++ b/src/types/TradeAboveThreshold.sol @@ -1,23 +1,14 @@ // 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 {IOrderManifest} from "../interfaces/IOrderManifest.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 +19,64 @@ 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, + GPv2Order.KIND_SELL, + false, + GPv2Order.BALANCE_ERC20, + GPv2Order.BALANCE_ERC20 + ); + } + + // ============ 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, @@ -62,5 +85,14 @@ contract TradeAboveThreshold is BaseConditionalOrder { 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 ad4f83b..562a0b5 100644 --- a/src/types/twap/TWAP.sol +++ b/src/types/twap/TWAP.sol @@ -1,67 +1,236 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; -import {ComposableCoW} from "../../ComposableCoW.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {ComposableCoW} from "../../ComposableCoW.sol"; import { IConditionalOrder, IConditionalOrderGenerator, 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"; -// --- 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 { + using SafeCast for uint256; + ComposableCoW public immutable composableCow; constructor(ComposableCoW _composableCow) { composableCow = _composableCow; } - /** - * @inheritdoc IConditionalOrderGenerator - * @dev `owner`, `sender` and `offchainInput` is not used. - */ - function getTradeableOrder(address owner, address, bytes32 ctx, bytes calldata staticInput, bytes calldata) + // ============ 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 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. - */ + 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) { + uint256 part = _currentPart(twap); + 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); + } + } + + // ============ IConditionalOrderGenerator Implementation ============ + + /// @inheritdoc IConditionalOrderGenerator + function getNextPollTimestamp(address owner, bytes32 ctx, bytes calldata staticInput, GPv2Order.Data memory) + external + view + override + returns (uint256) + { + TWAPOrder.Data memory twap = _resolveTwapData(owner, ctx, staticInput); + uint256 part = _currentPart(twap); + + // 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 = _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); } - order = TWAPOrder.orderFor(twap); + // Validate order parameters + try this.validateTwapData(twap) {} + catch { + return (new ManifestEntry[](0), false); + } + + // Calculate pagination bounds + uint256 totalParts = twap.n; + if (offset >= totalParts) { + return (new ManifestEntry[](0), false); + } + + uint256 remaining = totalParts - offset; + uint256 count = remaining < limit ? remaining : limit; + hasMore = offset + count < totalParts; - /// @dev Revert if the order is outside the TWAP bundle's span. - if (!(block.timestamp <= order.validTo)) { - revert IConditionalOrder.OrderNotValid(NOT_WITHIN_SPAN); + 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; + } } 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; + } } 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); } 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/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; + } +} 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.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") + }); + } +} 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 + ); + } +} 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) 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++) {