diff --git a/src/BaseConditionalOrder.sol b/src/BaseConditionalOrder.sol index 8ead77e..b10c7f4 100644 --- a/src/BaseConditionalOrder.sol +++ b/src/BaseConditionalOrder.sol @@ -195,7 +195,9 @@ abstract contract BaseConditionalOrder is IConditionalOrderGenerator, IOrderMani /** * @dev Decode revert data from `generateOrder` into a `GeneratorResult`. * - * Only `OrderNotValid` maps to the terminal `INVALID` verdict. Unrecognized + * Only `OrderNotValid` maps to the terminal `INVALID` verdict. + * `PollNeedsOffchainInput` maps to `NEEDS_INPUT` (acquire input or park, + * never a timed retry). 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 @@ -238,6 +240,15 @@ abstract contract BaseConditionalOrder is IConditionalOrderGenerator, IOrderMani return _verdict(IConditionalOrderGenerator.GeneratorResultCode.WAIT_BLOCK, blockNum, reasonCode); } + // PollNeedsOffchainInput(bytes4): not a timed retry - the caller + // must acquire non-empty offchainInput or park the order + if (selector == IConditionalOrder.PollNeedsOffchainInput.selector) { + return + _verdict( + IConditionalOrderGenerator.GeneratorResultCode.NEEDS_INPUT, 0, _decodeBytes4Error(errorData) + ); + } + // 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); diff --git a/src/interfaces/IConditionalOrder.sol b/src/interfaces/IConditionalOrder.sol index 97edb1e..c3e63a4 100644 --- a/src/interfaces/IConditionalOrder.sol +++ b/src/interfaces/IConditionalOrder.sol @@ -4,6 +4,11 @@ pragma solidity >=0.8.0 <0.9.0; import {GPv2Order} from "cowprotocol/contracts/libraries/GPv2Order.sol"; import {IERC165} from "safe/interfaces/IERC165.sol"; +/// @dev Canonical reason for `PollNeedsOffchainInput`: the handler cannot +/// generate with empty `offchainInput` and has no more specific reason. +/// Handlers MAY declare their own reason errors naming the missing input. +error OffchainInputRequired(); + /** * @title Conditional Order Interface * @author CoW Protocol Developers + mfw78 @@ -21,6 +26,11 @@ interface IConditionalOrder { 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 Generation requires non-empty `offchainInput` that the caller did not + /// supply. Unlike the timed poll errors, this is not a retry signal: + /// consumers MUST NOT re-poll with empty input on a schedule - acquire + /// the input (see `docs/discovery.md` on order modules) or park the order. + error PollNeedsOffchainInput(bytes4 reasonCode); /** * @dev This struct is used to uniquely identify a conditional order for an owner. @@ -110,7 +120,8 @@ interface IConditionalOrderGenerator is IConditionalOrder, IERC165 { 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 + INVALID, // Permanently invalid, stop polling + NEEDS_INPUT // Requires non-empty offchainInput; acquire input or park - never a timed retry } /** diff --git a/test/ComposableCoW.base.t.sol b/test/ComposableCoW.base.t.sol index 4681d4d..0660347 100644 --- a/test/ComposableCoW.base.t.sol +++ b/test/ComposableCoW.base.t.sol @@ -352,6 +352,39 @@ contract PollTryNextBlockHandler is BaseConditionalOrder { } /// @dev Test handler that throws PollTryAtTimestamp from generateOrder +/// @dev Test handler that requires non-empty offchainInput: reverts +/// PollNeedsOffchainInput when polled empty, generates when supplied. +contract NeedsOffchainInputHandler is BaseConditionalOrder { + bytes4 public reasonCode; + + constructor(bytes4 _reasonCode) { + reasonCode = _reasonCode; + } + + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata offchainInput) + public + view + override + returns (GPv2Order.Data memory) + { + require(offchainInput.length > 0, IConditionalOrder.PollNeedsOffchainInput(reasonCode)); + return GPv2Order.Data({ + sellToken: IERC20(address(0)), + buyToken: IERC20(address(0)), + receiver: address(0), + sellAmount: 0, + buyAmount: 0, + validTo: 0, + appData: keccak256(offchainInput), + feeAmount: 0, + kind: GPv2Order.KIND_SELL, + partiallyFillable: false, + sellTokenBalance: GPv2Order.BALANCE_ERC20, + buyTokenBalance: GPv2Order.BALANCE_ERC20 + }); + } +} + contract PollTryAtTimestampHandler is BaseConditionalOrder { uint256 public timestamp; bytes4 public reasonCode; diff --git a/test/ComposableCoW.poll.t.sol b/test/ComposableCoW.poll.t.sol index 7a7de36..b05ee14 100644 --- a/test/ComposableCoW.poll.t.sol +++ b/test/ComposableCoW.poll.t.sol @@ -9,6 +9,7 @@ import { BaseComposableCoWTest, InvalidHash, OrderNotValidHandler, + NeedsOffchainInputHandler, PollTryNextBlockHandler, PollTryAtTimestampHandler, PollTryAtBlockHandler, @@ -17,12 +18,14 @@ import { RequireFailHandler, UnknownErrorHandler } from "./ComposableCoW.base.t.sol"; +import {OffchainInputRequired} from "../src/interfaces/IConditionalOrder.sol"; /// @dev Test reason errors, as a handler would declare them error TestOrderInvalid(); error TestTryNextBlock(); error TestWaitTimestamp(); error TestWaitBlock(); +error TestTriggerPriceRequired(); /// @title Tests for poll() verdicts and error decoding in BaseConditionalOrder contract ComposableCoWPollTest is BaseComposableCoWTest { @@ -44,6 +47,48 @@ contract ComposableCoWPollTest is BaseComposableCoWTest { assertEq(result.nextPollTimestamp, 0); } + /// @dev PollNeedsOffchainInput decodes to the NEEDS_INPUT verdict: not a + /// timed retry - the caller must acquire input or park + function test_poll_DecodesPollNeedsOffchainInput() public { + bytes4 expectedReason = TestTriggerPriceRequired.selector; + NeedsOffchainInputHandler handler = new NeedsOffchainInputHandler(expectedReason); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.NEEDS_INPUT)); + assertEq(result.reasonCode, expectedReason); + assertEq(result.waitUntil, 0); + assertEq(result.nextPollTimestamp, 0); + } + + /// @dev The same handler generates once offchainInput is supplied - the + /// NEEDS_INPUT verdict is about the missing input, not the order + function test_poll_NeedsInputHandlerPostsWithInput() public { + NeedsOffchainInputHandler handler = new NeedsOffchainInputHandler(TestTriggerPriceRequired.selector); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("trigger")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.POST)); + assertEq(result.reasonCode, bytes4(0)); + } + + /// @dev The canonical OffchainInputRequired reason round-trips the probe: + /// tryGenerateOrder surfaces the full PollNeedsOffchainInput revert + function test_tryGenerateOrder_NeedsInputRevertData() public { + NeedsOffchainInputHandler handler = new NeedsOffchainInputHandler(OffchainInputRequired.selector); + + (bool success,, bytes memory revertData) = + handler.tryGenerateOrder(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertFalse(success); + assertEq( + revertData, + abi.encodeWithSelector(IConditionalOrder.PollNeedsOffchainInput.selector, OffchainInputRequired.selector) + ); + } + /// @dev PollTryNextBlock decodes to the TRY_NEXT_BLOCK verdict function test_poll_DecodesPollTryNextBlock() public { bytes4 expectedReason = TestTryNextBlock.selector;