@@ -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