From f7a0b44badd014bf51cf85feeac5c16745809dbd Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 30 Jul 2026 09:48:23 +0000 Subject: [PATCH] feat!: add structured poll interface for watch-towers Introduce the polling half of the dual-path architecture: - move generateOrder into IConditionalOrder as the single source of truth shared by verification and polling - add GeneratorResult / GeneratorResultCode to IConditionalOrderGenerator: poll() never reverts for order conditions, returning a structured verdict instead - add getNextPollTimestamp and describeOrder scheduling/UX hooks with sensible defaults in BaseConditionalOrder - decode recognized conditional-order errors into verdicts; map unrecognized reverts (including Panic and Error(string)) to TRY_NEXT_BLOCK so transient faults reschedule instead of permanently killing an order off-chain Fill state is deliberately absent from the generator surface: it is observed by the registry, not the handler, and lands with the registry poll path. BREAKING CHANGE: the IConditionalOrderGenerator ERC-165 interface id changes; existing deployed handlers will not satisfy the new id. --- src/BaseConditionalOrder.sol | 207 +++++++++++++++- src/interfaces/IConditionalOrder.sol | 122 +++++++++- src/types/GoodAfterTime.sol | 12 +- src/types/StopLoss.sol | 12 +- src/types/TradeAboveThreshold.sol | 2 +- test/ComposableCow.base.t.sol | 175 +++++++++++++- test/ComposableCow.gat.t.sol | 7 +- test/ComposableCow.poll.t.sol | 341 +++++++++++++++++++++++++++ test/ComposableCow.stoploss.t.sol | 20 +- test/ComposableCow.tat.t.sol | 2 +- 10 files changed, 865 insertions(+), 35 deletions(-) create mode 100644 test/ComposableCow.poll.t.sol diff --git a/src/BaseConditionalOrder.sol b/src/BaseConditionalOrder.sol index f613b8a4..a34e5520 100644 --- a/src/BaseConditionalOrder.sol +++ b/src/BaseConditionalOrder.sol @@ -5,21 +5,28 @@ import {GPv2Order, IERC20} from "cowprotocol/contracts/libraries/GPv2Order.sol"; import {IERC165, IConditionalOrder, IConditionalOrderGenerator} from "./interfaces/IConditionalOrder.sol"; -// --- error strings /** - * @dev This error is returned by the `verify` function if the *generated* order hash does not match - * the hash passed as a parameter. * @dev The generated order hash does not match the hash passed to `verify` */ error InvalidHash(); /** * @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. + * @dev Provides the dual-path plumbing: a lean `verify` for the settlement path and a + * structured, non-reverting `poll` for watch-towers, both derived from one + * `generateOrder` implementation. * @author mfw78 */ abstract contract BaseConditionalOrder is IConditionalOrderGenerator { + /** + * @dev Signals to poll at `order.validTo + 1` for the next discrete order. + */ + uint256 internal constant POLL_AT_VALIDTO = 0; + /** + * @dev Signals that this is the final discrete order; stop polling after it fills. + */ + uint256 internal constant POLL_NEVER = type(uint256).max; + /** * @inheritdoc IConditionalOrder * @dev As an order generator, the `GPv2Order.Data` passed as a parameter is ignored / not validated. @@ -37,11 +44,85 @@ abstract contract BaseConditionalOrder is IConditionalOrderGenerator { GPv2Order.Data memory generatedOrder = generateOrder(owner, sender, ctx, staticInput, offchainInput); /// @dev Verify that the *generated* order is valid and matches the payload. - if (!(_hash == GPv2Order.hash(generatedOrder, domainSeparator))) { - revert IConditionalOrder.OrderNotValid(InvalidHash.selector); + require( + _hash == GPv2Order.hash(generatedOrder, domainSeparator), + IConditionalOrder.OrderNotValid(InvalidHash.selector) + ); + } + + /** + * @inheritdoc IConditionalOrderGenerator + * @dev Wraps `generateOrder` in a try/catch, decoding conditional-order errors + * into a structured verdict. + */ + function poll(address owner, address sender, bytes32 ctx, bytes calldata staticInput, bytes calldata offchainInput) + external + view + override + returns (IConditionalOrderGenerator.GeneratorResult memory result) + { + try this.generateOrder(owner, sender, ctx, staticInput, offchainInput) returns (GPv2Order.Data memory order) { + uint256 nextPoll = this.getNextPollTimestamp(owner, ctx, staticInput, order); + return IConditionalOrderGenerator.GeneratorResult({ + code: IConditionalOrderGenerator.GeneratorResultCode.POST, + order: order, + nextPollTimestamp: nextPoll, + waitUntil: 0, + reasonCode: bytes4(0) + }); + } catch (bytes memory errorData) { + return _decodeErrorToGeneratorResult(errorData); + } + } + + /** + * @inheritdoc IConditionalOrderGenerator + * @dev The complete revert data is returned as ordinary return data so a + * consumer can inspect `Panic` sub-codes or unrecognized inner errors + * with their arguments, without any RPC revert-data handling. + */ + function tryGenerateOrder( + address owner, + address sender, + bytes32 ctx, + bytes calldata staticInput, + bytes calldata offchainInput + ) external view override returns (bool success, GPv2Order.Data memory order, bytes memory revertData) { + try this.generateOrder(owner, sender, ctx, staticInput, offchainInput) returns (GPv2Order.Data memory o) { + return (true, o, bytes("")); + } catch (bytes memory errorData) { + return (false, _emptyOrder(), errorData); } } + /** + * @inheritdoc IConditionalOrderGenerator + * @dev Default: poll at `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"; + } + /** * @dev Set the visibility of this function to `public` to allow `verify` to call it. * @inheritdoc IConditionalOrderGenerator @@ -60,4 +141,116 @@ abstract contract BaseConditionalOrder is IConditionalOrderGenerator { function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) { return interfaceId == type(IConditionalOrderGenerator).interfaceId || interfaceId == type(IERC165).interfaceId; } + + /** + * @dev Decode revert data from `generateOrder` into a `GeneratorResult`. + * + * Only `OrderNotValid` maps to the terminal `INVALID` verdict. Unrecognized + * reverts - including `Panic` and `Error(string)` - map to `TRY_NEXT_BLOCK` + * so a transient or recoverable fault reschedules rather than permanently + * killing the order off-chain. The `reasonCode` carries the selector of + * whatever was caught; the complete revert data is retrievable via + * `tryGenerateOrder`. + */ + function _decodeErrorToGeneratorResult(bytes memory errorData) + internal + pure + returns (IConditionalOrderGenerator.GeneratorResult memory) + { + if (errorData.length >= 4) { + bytes4 selector; + assembly { + selector := mload(add(errorData, 32)) + } + + // OrderNotValid(bytes4): the only permanently terminal verdict + if (selector == IConditionalOrder.OrderNotValid.selector) { + return + _verdict(IConditionalOrderGenerator.GeneratorResultCode.INVALID, 0, _decodeBytes4Error(errorData)); + } + + // PollTryNextBlock(bytes4) + if (selector == IConditionalOrderGenerator.PollTryNextBlock.selector) { + return _verdict( + IConditionalOrderGenerator.GeneratorResultCode.TRY_NEXT_BLOCK, 0, _decodeBytes4Error(errorData) + ); + } + + // PollTryAtTimestamp(uint256, bytes4) + if (selector == IConditionalOrderGenerator.PollTryAtTimestamp.selector) { + (uint256 timestamp, bytes4 reasonCode) = _decodeUintBytes4Error(errorData); + return _verdict(IConditionalOrderGenerator.GeneratorResultCode.WAIT_TIMESTAMP, timestamp, reasonCode); + } + + // PollTryAtBlock(uint256, bytes4) + if (selector == IConditionalOrderGenerator.PollTryAtBlock.selector) { + (uint256 blockNum, bytes4 reasonCode) = _decodeUintBytes4Error(errorData); + return _verdict(IConditionalOrderGenerator.GeneratorResultCode.WAIT_BLOCK, blockNum, reasonCode); + } + + // Anything else - Panic, Error(string), or an unrecognized custom + // error - is a transient verdict carrying the caught selector + return _verdict(IConditionalOrderGenerator.GeneratorResultCode.TRY_NEXT_BLOCK, 0, selector); + } + + // Empty or malformed revert data: transient, no reason available + return _verdict(IConditionalOrderGenerator.GeneratorResultCode.TRY_NEXT_BLOCK, 0, bytes4(0)); + } + + /** + * @dev Build a non-POST verdict with an empty order. + */ + function _verdict(IConditionalOrderGenerator.GeneratorResultCode code, uint256 waitUntil, bytes4 reasonCode) + private + pure + returns (IConditionalOrderGenerator.GeneratorResult memory) + { + return IConditionalOrderGenerator.GeneratorResult({ + code: code, order: _emptyOrder(), nextPollTimestamp: 0, waitUntil: waitUntil, reasonCode: reasonCode + }); + } + + /** + * @dev Decode an error with signature `(bytes4)`. + */ + function _decodeBytes4Error(bytes memory errorData) internal pure returns (bytes4 reasonCode) { + if (errorData.length >= 36) { + assembly { + errorData := add(errorData, 4) + } + reasonCode = abi.decode(errorData, (bytes4)); + } + } + + /** + * @dev Decode an error with signature `(uint256, bytes4)`. + */ + function _decodeUintBytes4Error(bytes memory errorData) internal pure returns (uint256 value, bytes4 reasonCode) { + if (errorData.length >= 68) { + assembly { + errorData := add(errorData, 4) + } + (value, reasonCode) = abi.decode(errorData, (uint256, bytes4)); + } + } + + /** + * @dev An all-zero order for non-POST verdicts. + */ + 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/interfaces/IConditionalOrder.sol b/src/interfaces/IConditionalOrder.sol index 97c6b810..e84b3071 100644 --- a/src/interfaces/IConditionalOrder.sol +++ b/src/interfaces/IConditionalOrder.sol @@ -2,7 +2,6 @@ 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"; /** @@ -17,14 +16,6 @@ interface IConditionalOrder { */ error OrderNotValid(bytes4 reasonCode); - // --- errors specific for polling - // Signal to a watch tower that polling should be attempted again. - error PollTryNextBlock(bytes4 reasonCode); - // Signal to a watch tower that polling should be attempted again at a specific block number. - error PollTryAtBlock(uint256 blockNumber, bytes4 reasonCode); - // Signal to a watch tower that polling should be attempted again at a specific timestamp. - error PollTryAtTimestamp(uint256 timestamp, bytes4 reasonCode); - /** * @dev This struct is used to uniquely identify a conditional order for an owner. * H(handler || salt || staticInput) **MUST** be unique for an owner. @@ -66,6 +57,10 @@ interface IConditionalOrder { /** * @title Conditional Order Generator Interface * @author mfw78 + * @notice Adds structured, non-reverting polling for watch-tower integration. + * @dev The generator surface carries the handler's *verdict* only. Observed fill + * state is orthogonal and is composed by the registry (`ComposableCow`), + * never by a handler. */ interface IConditionalOrderGenerator is IConditionalOrder, IERC165 { /** @@ -75,15 +70,26 @@ interface IConditionalOrderGenerator is IConditionalOrder, IERC165 { */ event ConditionalOrderCreated(address indexed owner, IConditionalOrder.ConditionalOrderParams params); + // --- errors specific for polling + // Signal to a watch tower that polling should be attempted again. + error PollTryNextBlock(bytes4 reasonCode); + // Signal to a watch tower that polling should be attempted again at a specific block number. + error PollTryAtBlock(uint256 blockNumber, bytes4 reasonCode); + // Signal to a watch tower that polling should be attempted again at a specific timestamp. + error PollTryAtTimestamp(uint256 timestamp, bytes4 reasonCode); + /** - * @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. + * Generate the discrete order for the current conditions. + * @dev The single source of truth for order derivation; `poll` is defined in + * terms of it. **MUST** revert with one of the conditional-order errors + * above if the order condition is not met. A generator's `verify` **MUST** + * accept exactly the orders this function produces for the same arguments. * @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 + * @return the discrete order for submission to the CoW Protocol API */ function generateOrder( address owner, @@ -92,4 +98,96 @@ interface IConditionalOrderGenerator is IConditionalOrder, IERC165 { bytes calldata staticInput, bytes calldata offchainInput ) external view returns (GPv2Order.Data memory); + + /** + * @dev The handler's verdict on whether a discrete order can be posted. + * These are the only values a handler can produce; fill state is + * deliberately not representable here. + */ + enum GeneratorResultCode { + POST, // A discrete order is ready to be posted + WAIT_TIMESTAMP, // Wait until `waitUntil` (unix timestamp) + WAIT_BLOCK, // Wait until `waitUntil` (block number) + TRY_NEXT_BLOCK, // Transient condition, retry next block + INVALID // Permanently invalid, stop polling + } + + /** + * @dev Structured result from `poll`. + * @param code the handler's verdict + * @param order the discrete order; only valid when `code == POST` + * @param nextPollTimestamp when to poll for the next discrete order after this + * one is posted; only meaningful when `code == POST`. `0` means poll at + * `order.validTo + 1`; `type(uint256).max` means this is the final order. + * @param waitUntil the timestamp (`WAIT_TIMESTAMP`) or block number + * (`WAIT_BLOCK`) to wait for; `0` otherwise + * @param reasonCode selector of the handler-declared error behind a non-POST + * verdict; `bytes4(0)` for POST. For a caught `Panic` or `Error(string)` + * this is that built-in's selector - use `tryGenerateOrder` to retrieve + * the complete revert data + */ + struct GeneratorResult { + GeneratorResultCode code; + GPv2Order.Data order; + uint256 nextPollTimestamp; + uint256 waitUntil; + bytes4 reasonCode; + } + + /** + * Poll for a discrete order with scheduling metadata. + * @dev Called by watch-towers. **MUST NOT** revert for order conditions: the + * conditional-order errors raised by `generateOrder` are decoded into the + * returned verdict instead. + * @param owner the contract who is the owner of the order + * @param sender the `msg.sender` of the poll 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 result structured verdict with the order (if postable) and scheduling hints + */ + function poll(address owner, address sender, bytes32 ctx, bytes calldata staticInput, bytes calldata offchainInput) + external + view + returns (GeneratorResult memory result); + + /** + * Probe order generation and return the full revert data on failure. + * @dev Diagnostic surface for off-chain consumers: unlike `poll`, the complete + * ABI-encoded inner error (including `Panic` sub-codes and unrecognized + * custom errors with their arguments) is returned as ordinary return + * data, avoiding RPC revert-data handling entirely. Never consulted on + * the settlement path. + * @return success whether a discrete order was generated + * @return order the discrete order (zeroed when `success` is false) + * @return revertData the complete ABI-encoded revert data (empty when + * `success` is true) + */ + function tryGenerateOrder( + address owner, + address sender, + bytes32 ctx, + bytes calldata staticInput, + bytes calldata offchainInput + ) external view returns (bool success, GPv2Order.Data memory order, bytes memory revertData); + + /** + * Get the scheduling hint for the next poll after a successful order. + * @dev Only consulted after `generateOrder` succeeds. + * @return nextPollTimestamp `0` = poll at `order.validTo + 1`; `type(uint256).max` = stop + * polling after this order; any other value = poll at that timestamp + */ + function getNextPollTimestamp(address owner, bytes32 ctx, bytes calldata staticInput, GPv2Order.Data memory order) + external + view + returns (uint256 nextPollTimestamp); + + /** + * Get a human-readable description of the discrete order. + * @dev Off-chain UX only; never consulted 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/types/GoodAfterTime.sol b/src/types/GoodAfterTime.sol index 6ad1464c..50e89932 100644 --- a/src/types/GoodAfterTime.sol +++ b/src/types/GoodAfterTime.sol @@ -4,7 +4,13 @@ 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, + GPv2Order, + BaseConditionalOrder, + IConditionalOrderGenerator +} from "../BaseConditionalOrder.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; // --- error strings @@ -67,7 +73,7 @@ contract GoodAfterTime is BaseConditionalOrder { // Don't allow the order to be placed before it becomes valid. if (!(block.timestamp >= data.startTime)) { - revert IConditionalOrder.PollTryAtTimestamp(data.startTime, TooEarly.selector); + revert IConditionalOrderGenerator.PollTryAtTimestamp(data.startTime, TooEarly.selector); } // Require that the sell token balance is above the minimum. @@ -87,7 +93,7 @@ contract GoodAfterTime is BaseConditionalOrder { // 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(PriceCheckerFailed.selector); + revert IConditionalOrderGenerator.PollTryNextBlock(PriceCheckerFailed.selector); } } diff --git a/src/types/StopLoss.sol b/src/types/StopLoss.sol index 7f7b3f18..91098654 100644 --- a/src/types/StopLoss.sol +++ b/src/types/StopLoss.sol @@ -1,7 +1,13 @@ // 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, + BaseConditionalOrder, + IConditionalOrderGenerator +} from "../BaseConditionalOrder.sol"; import {IAggregatorV3Interface} from "../interfaces/IAggregatorV3Interface.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; @@ -93,7 +99,7 @@ contract StopLoss is BaseConditionalOrder { /// @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(OracleStalePrice.selector); + revert IConditionalOrderGenerator.PollTryNextBlock(OracleStalePrice.selector); } // Normalize the decimals for basePrice and quotePrice, scaling them to 18 decimals @@ -103,7 +109,7 @@ contract StopLoss is BaseConditionalOrder { /// @dev Scale the strike price to 18 decimals. if (!(basePrice * SCALING_FACTOR / quotePrice <= data.strike)) { - revert IConditionalOrder.PollTryNextBlock(StrikeNotReached.selector); + revert IConditionalOrderGenerator.PollTryNextBlock(StrikeNotReached.selector); } } diff --git a/src/types/TradeAboveThreshold.sol b/src/types/TradeAboveThreshold.sol index fcd85980..bba4e744 100644 --- a/src/types/TradeAboveThreshold.sol +++ b/src/types/TradeAboveThreshold.sol @@ -47,7 +47,7 @@ contract TradeAboveThreshold is BaseConditionalOrder { 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(BalanceInsufficient.selector); + revert IConditionalOrderGenerator.PollTryNextBlock(BalanceInsufficient.selector); } // ensures that orders queried shortly after one another result in the same hash (to avoid spamming the orderbook) order = GPv2Order.Data( diff --git a/test/ComposableCow.base.t.sol b/test/ComposableCow.base.t.sol index 40326e3a..48a65431 100644 --- a/test/ComposableCow.base.t.sol +++ b/test/ComposableCow.base.t.sol @@ -11,7 +11,13 @@ 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, InvalidHash} from "../src/BaseConditionalOrder.sol"; +import { + IConditionalOrder, + IConditionalOrderGenerator, + IERC20, + BaseConditionalOrder, + InvalidHash +} from "../src/BaseConditionalOrder.sol"; import {BaseSwapGuard} from "../src/guards/BaseSwapGuard.sol"; import {TWAP, TWAPOrder} from "../src/types/twap/TWAP.sol"; @@ -297,6 +303,21 @@ contract TestNonSafeWallet is ERC1271Forwarder { * @dev A conditional order handler used for testing that reverts on verify */ contract MirrorConditionalOrder is IConditionalOrder { + /** + * @dev Not an interface member; this handler is a plain `IConditionalOrder`. + */ + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + external + pure + returns (GPv2Order.Data memory) + { + // use assembly to set the return data to calldata + assembly { + calldatacopy(0, 0, calldatasize()) + revert(0, calldatasize()) + } + } + function verify( address, address, @@ -314,3 +335,155 @@ contract MirrorConditionalOrder is IConditionalOrder { } } } + +/** + * @dev Test handler that throws OrderNotValid from generateOrder + */ +contract OrderNotValidHandler is BaseConditionalOrder { + bytes4 public reasonCode; + + constructor(bytes4 _reasonCode) { + reasonCode = _reasonCode; + } + + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + public + view + override + returns (GPv2Order.Data memory) + { + revert IConditionalOrder.OrderNotValid(reasonCode); + } +} + +/** + * @dev Test handler that throws PollTryNextBlock from generateOrder + */ +contract PollTryNextBlockHandler is BaseConditionalOrder { + bytes4 public reasonCode; + + constructor(bytes4 _reasonCode) { + reasonCode = _reasonCode; + } + + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + public + view + override + returns (GPv2Order.Data memory) + { + revert IConditionalOrderGenerator.PollTryNextBlock(reasonCode); + } +} + +/** + * @dev Test handler that throws PollTryAtTimestamp from generateOrder + */ +contract PollTryAtTimestampHandler is BaseConditionalOrder { + uint256 public timestamp; + bytes4 public reasonCode; + + constructor(uint256 _timestamp, bytes4 _reasonCode) { + timestamp = _timestamp; + reasonCode = _reasonCode; + } + + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + public + view + override + returns (GPv2Order.Data memory) + { + revert IConditionalOrderGenerator.PollTryAtTimestamp(timestamp, reasonCode); + } +} + +/** + * @dev Test handler that throws PollTryAtBlock from generateOrder + */ +contract PollTryAtBlockHandler is BaseConditionalOrder { + uint256 public blockNum; + bytes4 public reasonCode; + + constructor(uint256 _blockNum, bytes4 _reasonCode) { + blockNum = _blockNum; + reasonCode = _reasonCode; + } + + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + public + view + override + returns (GPv2Order.Data memory) + { + revert IConditionalOrderGenerator.PollTryAtBlock(blockNum, reasonCode); + } +} + +/** + * @dev Test handler that returns a configurable order from generateOrder + */ +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; + } +} + +/** + * @dev Test handler whose generateOrder hits an arithmetic Panic (division by zero) + */ +contract PanicHandler is BaseConditionalOrder { + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata offchainInput) + public + pure + override + returns (GPv2Order.Data memory) + { + uint256 divisor = offchainInput.length; // zero when called with empty input + uint256 x = 1 / divisor; + x; // silence unused warning + return _emptyOrder(); + } +} + +/** + * @dev Test handler whose generateOrder fails a bare require (Error(string)) + */ +contract RequireFailHandler is BaseConditionalOrder { + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + public + pure + override + returns (GPv2Order.Data memory) + { + require(false, "plain require failure"); + return _emptyOrder(); + } +} + +/** + * @dev Test handler whose generateOrder reverts with an unrecognized custom error + */ +contract UnknownErrorHandler is BaseConditionalOrder { + error SomethingUnexpected(uint256 code); + + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + public + pure + override + returns (GPv2Order.Data memory) + { + revert SomethingUnexpected(42); + } +} diff --git a/test/ComposableCow.gat.t.sol b/test/ComposableCow.gat.t.sol index 6c027d7d..c49446db 100644 --- a/test/ComposableCow.gat.t.sol +++ b/test/ComposableCow.gat.t.sol @@ -11,7 +11,8 @@ import { SafeLib, BaseComposableCowTest, ComposableCow, - ComposableCowLib + ComposableCowLib, + IConditionalOrderGenerator } from "./ComposableCow.base.t.sol"; import { @@ -55,7 +56,7 @@ contract ComposableCowGatTest is BaseComposableCowTest { // should revert when the current time is before the start time vm.expectRevert( - abi.encodeWithSelector(IConditionalOrder.PollTryAtTimestamp.selector, startTime, TooEarly.selector) + abi.encodeWithSelector(IConditionalOrderGenerator.PollTryAtTimestamp.selector, startTime, TooEarly.selector) ); gat.generateOrder(address(safe1), address(0), bytes32(0), abi.encode(o), abi.encode(uint256(1e18))); } @@ -104,7 +105,7 @@ contract ComposableCowGatTest is BaseComposableCowTest { deal(address(o.sellToken), address(safe1), o.minSellBalance); vm.expectRevert( - abi.encodeWithSelector(IConditionalOrder.PollTryNextBlock.selector, PriceCheckerFailed.selector) + abi.encodeWithSelector(IConditionalOrderGenerator.PollTryNextBlock.selector, PriceCheckerFailed.selector) ); gat.generateOrder(address(safe1), address(0), bytes32(0), abi.encode(o), abi.encode(buyAmount)); } diff --git a/test/ComposableCow.poll.t.sol b/test/ComposableCow.poll.t.sol new file mode 100644 index 00000000..790775f9 --- /dev/null +++ b/test/ComposableCow.poll.t.sol @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { + IConditionalOrder, + IConditionalOrderGenerator, + GPv2Order, + ComposableCow, + BaseComposableCowTest, + InvalidHash, + OrderNotValidHandler, + PollTryNextBlockHandler, + PollTryAtTimestampHandler, + PollTryAtBlockHandler, + SuccessHandler, + PanicHandler, + RequireFailHandler, + UnknownErrorHandler +} from "./ComposableCow.base.t.sol"; + +/** + * @dev Test reason errors, as a handler would declare them + */ +error TestOrderInvalid(); +error TestTryNextBlock(); +error TestWaitTimestamp(); +error TestWaitBlock(); + +/** + * @title Tests for poll() verdicts and error decoding in BaseConditionalOrder + */ +contract ComposableCowPollTest is BaseComposableCowTest { + function setUp() public virtual override(BaseComposableCowTest) { + super.setUp(); + } + + /** + * @dev OrderNotValid is the only error decoding to the terminal INVALID verdict + */ + function test_poll_DecodesOrderNotValid() public { + bytes4 expectedReason = TestOrderInvalid.selector; + OrderNotValidHandler handler = new OrderNotValidHandler(expectedReason); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.INVALID)); + assertEq(result.reasonCode, expectedReason); + assertEq(result.waitUntil, 0); + assertEq(result.nextPollTimestamp, 0); + } + + /** + * @dev PollTryNextBlock decodes to the TRY_NEXT_BLOCK verdict + */ + function test_poll_DecodesPollTryNextBlock() public { + bytes4 expectedReason = TestTryNextBlock.selector; + PollTryNextBlockHandler handler = new PollTryNextBlockHandler(expectedReason); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.TRY_NEXT_BLOCK)); + assertEq(result.reasonCode, expectedReason); + assertEq(result.waitUntil, 0); + assertEq(result.nextPollTimestamp, 0); + } + + /** + * @dev PollTryAtTimestamp decodes to the WAIT_TIMESTAMP verdict + */ + function test_poll_DecodesPollTryAtTimestamp() public { + uint256 expectedTimestamp = 1234567890; + bytes4 expectedReason = TestWaitTimestamp.selector; + PollTryAtTimestampHandler handler = new PollTryAtTimestampHandler(expectedTimestamp, expectedReason); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.WAIT_TIMESTAMP)); + assertEq(result.reasonCode, expectedReason); + assertEq(result.waitUntil, expectedTimestamp); + assertEq(result.nextPollTimestamp, 0); + } + + /** + * @dev PollTryAtBlock decodes to the WAIT_BLOCK verdict + */ + function test_poll_DecodesPollTryAtBlock() public { + uint256 expectedBlock = 999999; + bytes4 expectedReason = TestWaitBlock.selector; + PollTryAtBlockHandler handler = new PollTryAtBlockHandler(expectedBlock, expectedReason); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.WAIT_BLOCK)); + assertEq(result.reasonCode, expectedReason); + assertEq(result.waitUntil, expectedBlock); + assertEq(result.nextPollTimestamp, 0); + } + + /** + * @dev A successful generateOrder yields the POST verdict with the order + */ + function test_poll_ReturnsPostOnValidOrder() 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.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.POST)); + assertEq(result.reasonCode, bytes4(0)); + 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 An arithmetic Panic maps to TRY_NEXT_BLOCK with a diagnosable reason, + * never to the terminal INVALID verdict + */ + function test_poll_PanicMapsToTryNextBlock() public { + PanicHandler handler = new PanicHandler(); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.TRY_NEXT_BLOCK)); + // Panic(uint256) selector; the 0x12 sub-code is retrievable via tryGenerateOrder + assertEq(result.reasonCode, bytes4(0x4e487b71)); + } + + /** + * @dev A bare require reason maps to TRY_NEXT_BLOCK, preserving the reason + */ + function test_poll_BareRequireMapsToTryNextBlock() public { + RequireFailHandler handler = new RequireFailHandler(); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.TRY_NEXT_BLOCK)); + // Error(string) selector; the message is retrievable via tryGenerateOrder + assertEq(result.reasonCode, bytes4(0x08c379a0)); + } + + /** + * @dev An unrecognized custom error maps to TRY_NEXT_BLOCK, not INVALID + */ + function test_poll_UnknownErrorMapsToTryNextBlock() public { + UnknownErrorHandler handler = new UnknownErrorHandler(); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.TRY_NEXT_BLOCK)); + // The unrecognized error's own selector is carried through + assertEq(result.reasonCode, UnknownErrorHandler.SomethingUnexpected.selector); + } + + /** + * @dev Fuzz OrderNotValid decoding + */ + function test_poll_FuzzOrderNotValid(bytes4 reasonCode) public { + OrderNotValidHandler handler = new OrderNotValidHandler(reasonCode); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.INVALID)); + assertEq(result.reasonCode, reasonCode); + } + + /** + * @dev Fuzz PollTryAtTimestamp decoding + */ + function test_poll_FuzzPollTryAtTimestamp(uint256 timestamp, bytes4 reasonCode) public { + PollTryAtTimestampHandler handler = new PollTryAtTimestampHandler(timestamp, reasonCode); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.WAIT_TIMESTAMP)); + assertEq(result.waitUntil, timestamp); + assertEq(result.reasonCode, reasonCode); + } + + /** + * @dev Fuzz PollTryAtBlock decoding + */ + function test_poll_FuzzPollTryAtBlock(uint256 blockNum, bytes4 reasonCode) public { + PollTryAtBlockHandler handler = new PollTryAtBlockHandler(blockNum, reasonCode); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.WAIT_BLOCK)); + assertEq(result.waitUntil, blockNum); + assertEq(result.reasonCode, reasonCode); + } + + /** + * @dev verify() derives from generateOrder() and accepts a matching 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 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, InvalidHash.selector)); + handler.verify( + address(safe1), address(this), wrongHash, domainSeparator, bytes32(0), bytes(""), bytes(""), expectedOrder + ); + } + + // --- tryGenerateOrder: full revert data diagnostics --- + + /** + * @dev A Panic's complete revert data - including the sub-code - is returned + */ + function test_tryGenerateOrder_ReturnsFullPanicData() public { + PanicHandler handler = new PanicHandler(); + + (bool success,, bytes memory revertData) = + handler.tryGenerateOrder(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertFalse(success); + // Panic(0x12): division by zero + assertEq(revertData, abi.encodeWithSelector(bytes4(0x4e487b71), uint256(0x12))); + } + + /** + * @dev A bare require's Error(string) message is fully retrievable + */ + function test_tryGenerateOrder_ReturnsFullErrorString() public { + RequireFailHandler handler = new RequireFailHandler(); + + (bool success,, bytes memory revertData) = + handler.tryGenerateOrder(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertFalse(success); + assertEq(revertData, abi.encodeWithSignature("Error(string)", "plain require failure")); + } + + /** + * @dev An unrecognized custom error's arguments are fully retrievable + */ + function test_tryGenerateOrder_ReturnsFullCustomErrorData() public { + UnknownErrorHandler handler = new UnknownErrorHandler(); + + (bool success,, bytes memory revertData) = + handler.tryGenerateOrder(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertFalse(success); + assertEq(revertData, abi.encodeWithSelector(UnknownErrorHandler.SomethingUnexpected.selector, uint256(42))); + } + + /** + * @dev Success returns the order and empty revert data + */ + function test_tryGenerateOrder_SuccessReturnsOrder() public { + SuccessHandler handler = new SuccessHandler(); + GPv2Order.Data memory expectedOrder = getBlankOrder(); + expectedOrder.sellAmount = 42e18; + handler.setOrder(expectedOrder); + + (bool success, GPv2Order.Data memory order, bytes memory revertData) = + handler.tryGenerateOrder(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertTrue(success); + assertEq(order.sellAmount, 42e18); + assertEq(revertData.length, 0); + } +} diff --git a/test/ComposableCow.stoploss.t.sol b/test/ComposableCow.stoploss.t.sol index 88b6cd60..2cf7054f 100644 --- a/test/ComposableCow.stoploss.t.sol +++ b/test/ComposableCow.stoploss.t.sol @@ -1,7 +1,13 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; -import {IERC20, GPv2Order, IConditionalOrder, BaseComposableCowTest} from "./ComposableCow.base.t.sol"; +import { + IERC20, + GPv2Order, + IConditionalOrder, + BaseComposableCowTest, + IConditionalOrderGenerator +} from "./ComposableCow.base.t.sol"; import {IAggregatorV3Interface} from "../src/interfaces/IAggregatorV3Interface.sol"; import { StopLoss, @@ -69,7 +75,9 @@ contract ComposableCowStopLossTest is BaseComposableCowTest { createOrder(stopLoss, 0x0, abi.encode(data)); - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.PollTryNextBlock.selector, StrikeNotReached.selector)); + vm.expectRevert( + abi.encodeWithSelector(IConditionalOrderGenerator.PollTryNextBlock.selector, StrikeNotReached.selector) + ); stopLoss.generateOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); } @@ -107,7 +115,9 @@ contract ComposableCowStopLossTest is BaseComposableCowTest { maxTimeSinceLastOracleUpdate: staleTime }); - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.PollTryNextBlock.selector, StrikeNotReached.selector)); + vm.expectRevert( + abi.encodeWithSelector(IConditionalOrderGenerator.PollTryNextBlock.selector, StrikeNotReached.selector) + ); stopLoss.generateOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); } @@ -233,7 +243,9 @@ contract ComposableCowStopLossTest is BaseComposableCowTest { maxTimeSinceLastOracleUpdate: maxTimeSinceLastOracleUpdate }); - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.PollTryNextBlock.selector, OracleStalePrice.selector)); + vm.expectRevert( + abi.encodeWithSelector(IConditionalOrderGenerator.PollTryNextBlock.selector, OracleStalePrice.selector) + ); stopLoss.generateOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); } diff --git a/test/ComposableCow.tat.t.sol b/test/ComposableCow.tat.t.sol index 45745a88..2db7d981 100644 --- a/test/ComposableCow.tat.t.sol +++ b/test/ComposableCow.tat.t.sol @@ -36,7 +36,7 @@ contract ComposableCowTatTest is BaseComposableCowTest { // should revert when the current balance is below the minimum balance vm.expectRevert( - abi.encodeWithSelector(IConditionalOrder.PollTryNextBlock.selector, BalanceInsufficient.selector) + abi.encodeWithSelector(IConditionalOrderGenerator.PollTryNextBlock.selector, BalanceInsufficient.selector) ); tat.generateOrder(address(safe1), address(0), bytes32(0), abi.encode(o), bytes("")); }