Skip to content

Commit f1988b4

Browse files
committed
feat: pollFunds for just-in-time order funding
1 parent 04c1bde commit f1988b4

2 files changed

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

0 commit comments

Comments
 (0)