diff --git a/src/types/GoodAfterTime.sol b/src/types/GoodAfterTime.sol index 422f111..c9e543e 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, + IConditionalOrderGenerator, + GPv2Order, + BaseConditionalOrder +} from "../BaseConditionalOrder.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; // --- error strings @@ -100,4 +106,29 @@ contract GoodAfterTime is BaseConditionalOrder { GPv2Order.BALANCE_ERC20 ); } + + /** + * @inheritdoc IConditionalOrderGenerator + * @dev Single-shot order: once it fills there is nothing further to poll for. + */ + function getNextPollTimestamp(address, bytes32, bytes calldata, GPv2Order.Data memory) + external + pure + override + returns (uint256) + { + return POLL_NEVER; + } + + /** + * @inheritdoc IConditionalOrderGenerator + */ + function describeOrder(address, bytes32, bytes calldata, GPv2Order.Data memory) + external + pure + override + returns (string memory) + { + return "good-after-time order ready"; + } } diff --git a/src/types/StopLoss.sol b/src/types/StopLoss.sol index d7fac6b..c270e67 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, + IConditionalOrderGenerator, + BaseConditionalOrder +} from "../BaseConditionalOrder.sol"; import {IAggregatorV3Interface} from "../interfaces/IAggregatorV3Interface.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; @@ -112,4 +118,29 @@ contract StopLoss is BaseConditionalOrder { GPv2Order.BALANCE_ERC20 ); } + + /** + * @inheritdoc IConditionalOrderGenerator + * @dev Single-shot order: once it fills there is nothing further to poll for. + */ + function getNextPollTimestamp(address, bytes32, bytes calldata, GPv2Order.Data memory) + external + pure + override + returns (uint256) + { + return POLL_NEVER; + } + + /** + * @inheritdoc IConditionalOrderGenerator + */ + function describeOrder(address, bytes32, bytes calldata, GPv2Order.Data memory) + external + pure + override + returns (string memory) + { + return "stop-loss triggered"; + } } diff --git a/src/types/twap/TWAP.sol b/src/types/twap/TWAP.sol index d775376..f400c65 100644 --- a/src/types/twap/TWAP.sol +++ b/src/types/twap/TWAP.sol @@ -10,6 +10,7 @@ import { BaseConditionalOrder } from "../../BaseConditionalOrder.sol"; import {TWAPOrder} from "./libraries/TWAPOrder.sol"; +import {TWAPOrderMathLib, AfterTwapFinish} from "./libraries/TWAPOrderMathLib.sol"; // --- error strings @@ -48,7 +49,76 @@ contract TWAP is BaseConditionalOrder { * 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)); + TWAPOrder.Data memory twap = _resolveTwapData(owner, ctx, staticInput); + + order = TWAPOrder.orderFor(twap); + + /** + * @dev If outside the current part's span, this is a scheduling gap, not a + * permanent failure: signal when the next part starts, or terminate if + * there is no next part. + */ + 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(AfterTwapFinish.selector)); + revert IConditionalOrder.PollTryAtTimestamp(nextPartStart, NotWithinSpan.selector); + } + } + + /** + * @inheritdoc IConditionalOrderGenerator + * @dev The next discrete order becomes available when the next part starts; + * after the final part there is nothing left to poll for. + */ + 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. Written as `part + 1 >= n` + // to stay underflow-safe for degenerate bundles. + if (part + 1 >= twap.n) { + 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 + 1 >= twap.n) { + return "final twap part"; + } + return "twap part ready"; + } + + // --- internal helpers + + /// @dev Decode staticInput and resolve t0 from the 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)); /** * @dev If `twap.t0` is set to 0, then get the start time from the context. @@ -56,12 +126,10 @@ contract TWAP is BaseConditionalOrder { if (twap.t0 == 0) { twap.t0 = uint256(composableCow.cabinet(owner, ctx)); } + } - order = TWAPOrder.orderFor(twap); - - /// @dev Revert if the order is outside the TWAP bundle's span. - if (!(block.timestamp <= order.validTo)) { - revert IConditionalOrder.OrderNotValid(NotWithinSpan.selector); - } + /// @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); } } diff --git a/src/types/twap/libraries/TWAPOrderMathLib.sol b/src/types/twap/libraries/TWAPOrderMathLib.sol index eac6ca3..a385ef1 100644 --- a/src/types/twap/libraries/TWAPOrderMathLib.sol +++ b/src/types/twap/libraries/TWAPOrderMathLib.sol @@ -38,8 +38,11 @@ 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(BeforeTwapStart.selector); + /// @dev Order is not yet valid before the start (order commences at `t0`); + /// a watch tower should try again at the start time. + require( + startTime <= block.timestamp, IConditionalOrder.PollTryAtTimestamp(startTime, BeforeTwapStart.selector) + ); /** * @dev Order is expired after the last part (`n` parts, running at `t` time length). @@ -50,9 +53,10 @@ 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(AfterTwapFinish.selector); - } + require( + block.timestamp < startTime + (numParts * frequency), + IConditionalOrder.OrderNotValid(AfterTwapFinish.selector) + ); /** * @dev We use integer division to get the part number as we want to round down to the nearest part. @@ -102,4 +106,14 @@ 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/test/ComposableCoW.gat.t.sol b/test/ComposableCoW.gat.t.sol index 817d6f6..a2c7820 100644 --- a/test/ComposableCoW.gat.t.sol +++ b/test/ComposableCoW.gat.t.sol @@ -367,6 +367,12 @@ contract ComposableCoWGatTest is BaseComposableCoWTest { appData: keccak256("GoodAfterTime") }); } + + /// @dev Single-shot: stop polling after fill, and a descriptive reason + function test_pollHints_SingleShot() public { + assertEq(gat.getNextPollTimestamp(address(0), bytes32(0), bytes(""), getBlankOrder()), type(uint256).max); + assertEq(gat.describeOrder(address(0), bytes32(0), bytes(""), getBlankOrder()), "good-after-time order ready"); + } } /// @dev A test implementation that returns what we tell it to.. diff --git a/test/ComposableCoW.stoploss.t.sol b/test/ComposableCoW.stoploss.t.sol index 57ff8e5..fc37e4c 100644 --- a/test/ComposableCoW.stoploss.t.sol +++ b/test/ComposableCoW.stoploss.t.sol @@ -345,4 +345,10 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { assertEq(order.sellTokenBalance, GPv2Order.BALANCE_ERC20); assertEq(order.buyTokenBalance, GPv2Order.BALANCE_ERC20); } + + /// @dev Single-shot: stop polling after fill, and a descriptive reason + function test_pollHints_SingleShot() public { + assertEq(stopLoss.getNextPollTimestamp(address(0), bytes32(0), bytes(""), getBlankOrder()), type(uint256).max); + assertEq(stopLoss.describeOrder(address(0), bytes32(0), bytes(""), getBlankOrder()), "stop-loss triggered"); + } } diff --git a/test/ComposableCoW.twap.t.sol b/test/ComposableCoW.twap.t.sol index 353764f..580f116 100644 --- a/test/ComposableCoW.twap.t.sol +++ b/test/ComposableCoW.twap.t.sol @@ -199,7 +199,9 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { // Warp to current time vm.warp(currentTime); - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, BeforeTwapStart.selector)); + vm.expectRevert( + abi.encodeWithSelector(IConditionalOrder.PollTryAtTimestamp.selector, startTime, BeforeTwapStart.selector) + ); twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } @@ -253,7 +255,16 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { // Warp to outside of the span vm.warp(currentTime); - vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, NotWithinSpan.selector)); + uint256 nextPartStart = startTime + (((currentTime - startTime) / FREQUENCY + 1) * FREQUENCY); + if (nextPartStart < startTime + (FREQUENCY * NUM_PARTS)) { + vm.expectRevert( + abi.encodeWithSelector( + IConditionalOrder.PollTryAtTimestamp.selector, nextPartStart, NotWithinSpan.selector + ) + ); + } else { + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, AfterTwapFinish.selector)); + } twap.generateOrder(address(0), address(0), bytes32(0), abi.encode(o), bytes("")); } @@ -280,12 +291,15 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { // Warp to the current time vm.warp(currentTime); - // The below should return the terminal INVALID verdict - (ComposableCoW.PollResult memory invalidRes, bytes memory invalidSig) = + // The below should return a WAIT_TIMESTAMP verdict pointing at the start + (ComposableCoW.PollResult memory waitRes, bytes memory waitSig) = composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), new bytes32[](0)); - assertEq(uint256(invalidRes.generator.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.INVALID)); - assertEq(invalidRes.generator.reasonCode, BeforeTwapStart.selector); - assertEq(invalidSig.length, 0); + assertEq( + uint256(waitRes.generator.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.WAIT_TIMESTAMP) + ); + assertEq(waitRes.generator.waitUntil, ctxBlockTimestamp); + assertEq(waitRes.generator.reasonCode, BeforeTwapStart.selector); + assertEq(waitSig.length, 0); } function test_generateOrder_FuzzRevertIfOrderAfterBlocktimestampValidity( @@ -685,4 +699,35 @@ contract ComposableCoWTwapTest is BaseComposableCoWTest { appData: keccak256("test.twap") }); } + + /// @dev getNextPollTimestamp points at the next part while parts remain + function test_getNextPollTimestamp_PointsAtNextPart() public { + TWAPOrder.Data memory o = _twapTestBundle(block.timestamp); + + // Within the first part + vm.warp(o.t0); + uint256 nextPoll = twap.getNextPollTimestamp(address(0), bytes32(0), abi.encode(o), getBlankOrder()); + assertEq(nextPoll, o.t0 + o.t); + } + + /// @dev getNextPollTimestamp signals POLL_NEVER on the final part + function test_getNextPollTimestamp_FinalPartStopsPolling() public { + TWAPOrder.Data memory o = _twapTestBundle(block.timestamp); + + // Warp into the final part + vm.warp(o.t0 + (o.n - 1) * o.t); + uint256 nextPoll = twap.getNextPollTimestamp(address(0), bytes32(0), abi.encode(o), getBlankOrder()); + assertEq(nextPoll, type(uint256).max); + } + + /// @dev describeOrder distinguishes the final part + function test_describeOrder_TwapParts() public { + TWAPOrder.Data memory o = _twapTestBundle(block.timestamp); + + vm.warp(o.t0); + assertEq(twap.describeOrder(address(0), bytes32(0), abi.encode(o), getBlankOrder()), "twap part ready"); + + vm.warp(o.t0 + (o.n - 1) * o.t); + assertEq(twap.describeOrder(address(0), bytes32(0), abi.encode(o), getBlankOrder()), "final twap part"); + } }