Skip to content

Commit d07ce51

Browse files
committed
feat: pollFunds for just-in-time order funding
1 parent bf350d1 commit d07ce51

2 files changed

Lines changed: 211 additions & 3 deletions

File tree

src/types/ComposableCowPoller.sol

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
// SPDX-License-Identifier: GPL-3.0
22
pragma 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
contract ComposableCowPoller {
11+
ComposableCoW public immutable composableCow;
12+
813
struct Schedule {
914
IConditionalOrderGenerator handler; // the conditional-order handler to poll (e.g. the TWAP type)
1015
address funder; // source of funds; the only registrant
@@ -17,9 +22,19 @@ contract ComposableCowPoller {
1722
/// order's `appData`.
1823
mapping(bytes32 => Schedule) public schedules;
1924

25+
/// @dev `id => digest` of the order last funded, so each order is funded at most once.
26+
mapping(bytes32 => bytes32) public lastFunded;
27+
2028
error OnlyFunder();
29+
error NoSchedule();
30+
error OrderNotLive();
2131

2232
event ScheduleRegistered(bytes32 indexed id, address indexed owner, address indexed funder);
33+
event Pulled(bytes32 indexed id, bytes32 orderDigest, uint256 amount);
34+
35+
constructor(ComposableCoW _composableCow) {
36+
composableCow = _composableCow;
37+
}
2338

2439
/// @notice Deterministic, appData-independent schedule key.
2540
function scheduleId(address funder, IConditionalOrderGenerator handler, address owner, bytes32 salt)
@@ -36,6 +51,39 @@ contract ComposableCowPoller {
3651
if (msg.sender != schedule.funder) revert OnlyFunder();
3752
id = scheduleId(schedule.funder, schedule.handler, schedule.owner, schedule.salt);
3853
schedules[id] = schedule;
54+
delete lastFunded[id]; // reset funding history
3955
emit ScheduleRegistered(id, schedule.owner, schedule.funder);
4056
}
57+
58+
/// @notice Move the current order's `sellAmount` from the funder to the owner. Permissionless.
59+
/// The full amount always moves (no balance check), so one owner can serve several
60+
/// concurrent orders.
61+
function pollFunds(bytes32 id) external {
62+
Schedule memory schedule = schedules[id];
63+
if (schedule.funder == address(0)) revert NoSchedule();
64+
65+
// Re-derive `ctx` on-chain, so `pollFunds(id)` stays independent of the order's `appData`.
66+
bytes32 ctx = composableCow.hash(
67+
IConditionalOrder.ConditionalOrderParams({
68+
handler: schedule.handler,
69+
salt: schedule.salt,
70+
staticInput: schedule.staticInput
71+
})
72+
);
73+
74+
// The order must still be authorised; `remove` disables the poller.
75+
if (!composableCow.singleOrders(schedule.owner, ctx)) revert OrderNotLive();
76+
77+
// The handler yields the current order and reverts outside its window.
78+
GPv2Order.Data memory order =
79+
schedule.handler.getTradeableOrder(schedule.owner, address(this), ctx, schedule.staticInput, bytes(""));
80+
81+
// Fund each order at most once.
82+
bytes32 digest = GPv2Order.hash(order, composableCow.domainSeparator());
83+
if (digest == lastFunded[id]) return;
84+
lastFunded[id] = digest;
85+
86+
order.sellToken.transferFrom(schedule.funder, schedule.owner, order.sellAmount);
87+
emit Pulled(id, digest, order.sellAmount);
88+
}
4189
}

test/ComposableCowPoller.t.sol

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ contract ComposableCowPollerTest is BaseComposableCoWTest {
2828

2929
twap = new TWAP(composableCow);
3030
currentBlockTimestampFactory = new CurrentBlockTimestampFactory();
31-
poller = new ComposableCowPoller();
31+
poller = new ComposableCowPoller(composableCow);
3232
funder = makeAddr("funder");
3333

3434
// The owner (safe1) starts with no sell token: funds arrive just-in-time.
@@ -54,7 +54,7 @@ contract ComposableCowPollerTest is BaseComposableCoWTest {
5454
/// @dev Creates a JIT-funded TWAP: order via context, the funder funds + approves the poller,
5555
/// and the schedule is registered. Returns the order's cabinet key `ctx`
5656
/// (`ComposableCoW.hash(params)`, used for cabinet/remove) and the appData-independent
57-
/// poller schedule key `id` (used for topUp/revoke).
57+
/// poller schedule key `id` (used for pollFunds/revoke).
5858
function _setupSchedule()
5959
internal
6060
returns (
@@ -105,6 +105,12 @@ contract ComposableCowPollerTest is BaseComposableCoWTest {
105105
);
106106
}
107107

108+
/// @dev The order's resolved start time `t0`, read back from the cabinet where
109+
/// `createWithContext` stored it via `CurrentBlockTimestampFactory`.
110+
function _t0(bytes32 ctx) internal view returns (uint256) {
111+
return uint256(composableCow.cabinet(address(safe1), ctx));
112+
}
113+
108114
/// @dev A registered schedule is stored under its appData-independent id.
109115
function test_register_storesSchedule() public {
110116
(, , bytes32 id) = _setupSchedule();
@@ -130,4 +136,158 @@ contract ComposableCowPollerTest is BaseComposableCoWTest {
130136
})
131137
);
132138
}
139+
140+
/// @dev A single poll moves exactly the current part into the owner.
141+
function test_pollFunds_fundsCurrentPart() public {
142+
(, bytes32 ctx, bytes32 id) = _setupSchedule();
143+
vm.warp(_t0(ctx));
144+
145+
assertEq(
146+
token0.balanceOf(address(safe1)),
147+
0,
148+
"owner empty before pull"
149+
);
150+
151+
poller.pollFunds(id);
152+
153+
assertEq(
154+
token0.balanceOf(address(safe1)),
155+
PART,
156+
"owner funded with exactly one part"
157+
);
158+
assertEq(
159+
token0.balanceOf(funder),
160+
PART * N - PART,
161+
"exactly one part left the funder"
162+
);
163+
}
164+
165+
/// @dev Funds move unconditionally: even if the owner already holds a balance (e.g. from another
166+
/// concurrent order), the full part is still pulled, so orders never share funding.
167+
function test_pollFunds_movesFullAmountUnconditionally() public {
168+
(, bytes32 ctx, bytes32 id) = _setupSchedule();
169+
vm.warp(_t0(ctx));
170+
171+
// The owner already holds an unrelated balance (e.g. funded for another order).
172+
deal(address(token0), address(safe1), PART);
173+
174+
poller.pollFunds(id);
175+
176+
assertEq(
177+
token0.balanceOf(address(safe1)),
178+
PART * 2,
179+
"full part pulled on top of the existing balance"
180+
);
181+
assertEq(
182+
token0.balanceOf(funder),
183+
PART * N - PART,
184+
"a full part left the funder"
185+
);
186+
}
187+
188+
/// @dev Repeated calls for the same part are a no-op (guarded by the order digest).
189+
function test_pollFunds_idempotentWithinPart() public {
190+
(, bytes32 ctx, bytes32 id) = _setupSchedule();
191+
vm.warp(_t0(ctx));
192+
193+
poller.pollFunds(id);
194+
poller.pollFunds(id); // no-op: this part has already been funded
195+
196+
assertEq(
197+
token0.balanceOf(address(safe1)),
198+
PART,
199+
"still exactly one part"
200+
);
201+
assertEq(token0.balanceOf(funder), PART * N - PART, "no extra pull");
202+
}
203+
204+
/// @dev The headline flow: each part is funded JIT and the owner holds nothing in between.
205+
function test_pollFunds_fundsEachPartAcrossSchedule() public {
206+
(, bytes32 ctx, bytes32 id) = _setupSchedule();
207+
uint256 t0 = _t0(ctx);
208+
209+
for (uint256 part = 0; part < N; part++) {
210+
vm.warp(t0 + part * FREQ);
211+
212+
assertEq(
213+
token0.balanceOf(address(safe1)),
214+
0,
215+
"owner empty before part"
216+
);
217+
poller.pollFunds(id);
218+
assertEq(token0.balanceOf(address(safe1)), PART, "part funded");
219+
220+
// Simulate the part settling: the owner's balance is consumed.
221+
vm.prank(address(safe1));
222+
token0.transfer(bob.addr, PART);
223+
224+
assertEq(
225+
token0.balanceOf(funder),
226+
PART * N - PART * (part + 1),
227+
"one part funded per window"
228+
);
229+
}
230+
}
231+
232+
/// @dev The anti-premature-execution guard: once a part is funded, the *next* part's funds
233+
/// cannot be pulled until time advances into its window — even after the part settles and
234+
/// drains the owner. Without the per-order guard, the drained owner would be refilled
235+
/// immediately and that balance would be sold as the next part, a full interval early.
236+
function test_pollFunds_cannotFundFuturePartEarly() public {
237+
(, bytes32 ctx, bytes32 id) = _setupSchedule();
238+
vm.warp(_t0(ctx)); // part 0 window
239+
240+
poller.pollFunds(id);
241+
assertEq(token0.balanceOf(address(safe1)), PART, "part 0 funded");
242+
243+
// Simulate the part settling: the owner's balance is consumed.
244+
vm.prank(address(safe1));
245+
token0.transfer(bob.addr, PART);
246+
assertEq(
247+
token0.balanceOf(address(safe1)),
248+
0,
249+
"owner drained by the fill"
250+
);
251+
252+
// Still inside part 0's window: a fresh pull must NOT refill.
253+
poller.pollFunds(id);
254+
255+
assertEq(
256+
token0.balanceOf(address(safe1)),
257+
0,
258+
"next part not funded early"
259+
);
260+
assertEq(
261+
token0.balanceOf(funder),
262+
PART * N - PART,
263+
"exactly one part ever left the funder"
264+
);
265+
}
266+
267+
/// @dev The pull is bounded to the schedule window: after it ends, `getTradeableOrder` reverts.
268+
function test_pollFunds_RevertWhen_scheduleEnded() public {
269+
(, bytes32 ctx, bytes32 id) = _setupSchedule();
270+
vm.warp(_t0(ctx) + N * FREQ);
271+
272+
vm.expectRevert(); // IConditionalOrder.OrderNotValid(...) from the handler
273+
poller.pollFunds(id);
274+
}
275+
276+
/// @dev An unregistered schedule cannot be polled.
277+
function test_pollFunds_RevertWhen_noSchedule() public {
278+
vm.expectRevert(ComposableCowPoller.NoSchedule.selector);
279+
poller.pollFunds(keccak256("unknown"));
280+
}
281+
282+
/// @dev Cancelling the order flips `singleOrders` false, which disables the poller for free.
283+
function test_remove_killsPoller() public {
284+
(, bytes32 ctx, bytes32 id) = _setupSchedule();
285+
vm.warp(_t0(ctx));
286+
287+
vm.prank(address(safe1));
288+
composableCow.remove(ctx);
289+
290+
vm.expectRevert(ComposableCowPoller.OrderNotLive.selector);
291+
poller.pollFunds(id);
292+
}
133293
}

0 commit comments

Comments
 (0)