11// SPDX-License-Identifier: GPL-3.0
22pragma solidity >= 0.8.0 < 0.9.0 ;
33
4- import {IConditionalOrderGenerator} from "../interfaces/IConditionalOrder.sol " ;
4+ import {IERC20 , GPv2Order} from "cowprotocol/contracts/libraries/GPv2Order.sol " ;
5+
6+ import {ComposableCoW} from "../ComposableCoW.sol " ;
7+ import {IConditionalOrder, IConditionalOrderGenerator} from "../interfaces/IConditionalOrder.sol " ;
58
69/// @title ComposableCowPoller - Just-in-time funding for composable conditional orders.
710/// @notice Pulls exactly the current discrete order's `sellAmount` from a funds source into the
811/// order owner, immediately before that order settles. Designed to be invoked from a CoW
912/// Protocol pre-hook so capital can sit in the user's EOA (or a treasury) between parts
1013/// instead of being locked up front for the whole order (e.g. the full notional of a
1114/// long-running TWAP / DCA).
12- /// @dev This is the generic "ComposableCowPoller". It is not tied to any single order type; the
15+ /// @dev This is the generic "ComposableCowPoller". It is not tied to any single order type. The
1316/// handler to poll is supplied per-schedule, and the amount / sell token / validity window
14- /// are all read from that handler's own `getTradeableOrder`.
17+ /// are all read from that handler's own `getTradeableOrder`, so the poller carries no
18+ /// order-type-specific logic of its own.
19+ ///
20+ /// Self-funding pre-hooks. A schedule is keyed by an `id` that is *independent of the
21+ /// order's `appData`* (`id = scheduleId(funder, handler, owner, salt)`). This is what makes
22+ /// `topUp` usable from the order's own appData: the conditional order's `ctx` contains its
23+ /// `appData` hash, so a hook that referenced `ctx` (or `staticInput`, or the order digest)
24+ /// could never be embedded in that same `appData` — it would be circular. By keying on
25+ /// appData-independent fields and re-deriving `ctx` on-chain from the stored params, the
26+ /// hook calldata `topUp(id)` no longer depends on `appData`, breaking the cycle. Every
27+ /// discrete part inherits the parent order's `appData`, so each part funds itself JIT.
1528///
16- /// A schedule is keyed by an `id` that is *independent of the order's `appData`*
17- /// (`id = scheduleId(funder, handler, owner, salt)`). This is what makes the funding hook
18- /// usable from the order's own appData: the conditional order's `ctx` contains its `appData`
19- /// hash, so a hook that referenced `ctx` (or `staticInput`, or the order digest) could never
20- /// be embedded in that same `appData` — it would be circular. Keying on appData-independent
21- /// fields breaks the cycle.
29+ /// Security model. `topUp` is intentionally permissionless: safety comes from constraining
30+ /// *what* a call can do, never *who* makes it:
31+ ///
32+ /// 1. The order must still be authorised: `ComposableCoW.singleOrders(owner, ctx)` must be
33+ /// true, where `ctx` is re-derived on-chain from the stored params. `ComposableCoW.remove`
34+ /// flips it false, which disables the poller for free.
35+ /// 2. The amount, the sell token and the validity window are all taken from the handler's
36+ /// own `getTradeableOrder` — never from caller-supplied arguments. Outside an active
37+ /// window `getTradeableOrder` reverts, so funds can never be pulled while no discrete
38+ /// order is tradeable.
39+ /// 3. The destination (`owner`) is read from the schedule the funder registered, so a pull
40+ /// can only ever move funds to the owner's own account.
41+ /// 4. Each discrete order is funded at most once, keyed by its unique order digest. This is
42+ /// the anti-"premature execution" guard: because `getTradeableOrder` only ever yields the
43+ /// *current* order, and a given digest is never re-funded, the *next* part's funds can
44+ /// never be pulled before its own window opens.
45+ /// 5. The top-up is balance-capped to the current order, so repeated calls are idempotent
46+ /// and the owner never holds more than one order's worth at a time.
47+ /// 6. The schedule `id` is namespaced by `funder`, so nobody can squat or overwrite another
48+ /// funder's schedule.
2249contract ComposableCowPoller {
50+ /// @dev The ComposableCoW instance that authorises the order. Also the source of the domain
51+ /// separator used to compute the order digest, and of `hash(params)` used to derive `ctx`.
52+ ComposableCoW public immutable composableCow;
53+
2354 struct Schedule {
2455 IConditionalOrderGenerator handler; // the conditional-order handler to poll (e.g. the TWAP type)
2556 address funder; // source of funds (the EOA in the TWAP-for-EOA flow); the only registrant
@@ -33,12 +64,24 @@ contract ComposableCowPoller {
3364 /// that same `appData`. Every field of `id` is appData-independent.
3465 mapping (bytes32 => Schedule) public schedules;
3566
67+ /// @dev `id => digest` of the discrete order most recently funded. Each part has a distinct
68+ /// digest, so this enforces "fund each discrete order at most once" and prevents the next
69+ /// part from being funded before its own window opens.
70+ mapping (bytes32 => bytes32 ) public lastFunded;
71+
3672 error OnlyFunder ();
73+ error NoSchedule ();
74+ error OrderNotLive ();
3775
3876 event ScheduleRegistered (bytes32 indexed id , address indexed owner , address indexed funder );
77+ event Pulled (bytes32 indexed id , bytes32 orderDigest , uint256 amount );
78+
79+ constructor (ComposableCoW _composableCow ) {
80+ composableCow = _composableCow;
81+ }
3982
4083 /// @notice Deterministic, appData-independent schedule key. Integrators compute this off-chain
41- /// to embed the funding hook in the order's appData, and it is re-derived here on `register`.
84+ /// to embed `topUp(id)` in the order's appData, and it is re-derived here on `register`.
4285 function scheduleId (address funder , IConditionalOrderGenerator handler , address owner , bytes32 salt )
4386 public
4487 pure
@@ -54,6 +97,51 @@ contract ComposableCowPoller {
5497 if (msg .sender != schedule.funder) revert OnlyFunder ();
5598 id = scheduleId (schedule.funder, schedule.handler, schedule.owner, schedule.salt);
5699 schedules[id] = schedule;
100+ // A re-registration starts a fresh funding history for this id.
101+ delete lastFunded[id];
57102 emit ScheduleRegistered (id, schedule.owner, schedule.funder);
58103 }
104+
105+ /// @notice Fund the order owner with the current discrete order's `sellAmount`. Intended as a
106+ /// pre-hook embedded in the order's own appData, but permissionless: the guards below
107+ /// make the caller irrelevant.
108+ /// @dev Idempotent within an order, and a no-op once that order has been funded. Reverts
109+ /// outside an active window (delegated to the handler's `getTradeableOrder`).
110+ function topUp (bytes32 id ) external {
111+ Schedule memory schedule = schedules[id];
112+ if (schedule.funder == address (0 )) revert NoSchedule ();
113+
114+ // Re-derive `ctx` on-chain from the stored params. `id` is appData-independent, so the hook
115+ // calldata `topUp(id)` does not depend on `appData` — that is what breaks the cycle that
116+ // would otherwise prevent embedding this call in the order's own appData.
117+ bytes32 ctx = composableCow.hash (
118+ IConditionalOrder.ConditionalOrderParams ({
119+ handler: schedule.handler,
120+ salt: schedule.salt,
121+ staticInput: schedule.staticInput
122+ })
123+ );
124+
125+ // The order must still be authorised. `remove` flips this false, disabling the poller.
126+ if (! composableCow.singleOrders (schedule.owner, ctx)) revert OrderNotLive ();
127+
128+ // Reuse the handler's own logic: this reverts outside the active window and yields the
129+ // exact discrete order. The amount and token come from here, so the caller controls
130+ // nothing that could change how much moves or in which asset.
131+ GPv2Order.Data memory order =
132+ schedule.handler.getTradeableOrder (schedule.owner, address (this ), ctx, schedule.staticInput, bytes ("" ));
133+
134+ // Fund each discrete order at most once, keyed by its unique digest.
135+ bytes32 digest = GPv2Order.hash (order, composableCow.domainSeparator ());
136+ if (digest == lastFunded[id]) return ;
137+ lastFunded[id] = digest;
138+
139+ // Top up the owner to exactly the current order. The destination is fixed by the schedule.
140+ uint256 balance = order.sellToken.balanceOf (schedule.owner);
141+ if (balance < order.sellAmount) {
142+ uint256 deficit = order.sellAmount - balance;
143+ order.sellToken.transferFrom (schedule.funder, schedule.owner, deficit);
144+ emit Pulled (id, digest, deficit);
145+ }
146+ }
59147}
0 commit comments