11// SPDX-License-Identifier: GPL-3.0
22pragma solidity >= 0.8.0 < 0.9.0 ;
33
4+ import {GPv2Order} from "cowprotocol/contracts/libraries/GPv2Order.sol " ;
5+
46import {IConditionalOrder, IValueFactory, BaseComposableCoWTest} from "test/ComposableCoW.base.t.sol " ;
57
68import {TWAP} from "src/types/twap/TWAP.sol " ;
@@ -166,68 +168,88 @@ contract ComposableCowPollerTest is BaseComposableCoWTest {
166168 );
167169 }
168170
169- /// @dev A single poll moves exactly the current part into the owner.
170- function test_pollFunds_fundsCurrentPart () public {
171+ /// @dev Funds move unconditionally: even if the owner already holds a balance (e.g. from another
172+ /// concurrent order), the full part is still pulled, so orders never share funding.
173+ function test_pollFunds_movesFullAmountUnconditionally () public {
171174 (, bytes32 ctx , bytes32 id ) = _setupSchedule ();
172175 vm.warp (_t0 (ctx));
173176
174- assertEq (
175- token0.balanceOf (address (safe1)),
176- 0 ,
177- "owner empty before pull "
178- );
177+ // The owner already holds an unrelated balance (e.g. funded for another order).
178+ deal (address (token0), address (safe1), TWAP_PART_AMOUNT);
179179
180180 poller.pollFunds (id);
181181
182182 assertEq (
183- token0.balanceOf (address (safe1)),
184- PART,
185- "owner funded with exactly one part "
186- );
187- assertEq (
188- token0.balanceOf (funder),
189- PART * N - PART,
190- "exactly one part left the funder "
183+ token0.balanceOf (address (safe1)), TWAP_PART_AMOUNT * 2 , "full part pulled on top of the existing balance "
191184 );
185+ assertEq (token0.balanceOf (funder), TWAP_PART_AMOUNT * N - TWAP_PART_AMOUNT, "a full part left the funder " );
192186 }
193187
194- /// @dev Funds move unconditionally: even if the owner already holds a balance (e.g. from another
195- /// concurrent order), the full part is still pulled, so orders never share funding.
196- function test_pollFunds_movesFullAmountUnconditionally () public {
188+ /// @dev A repeated call in the same part is a no-op, even after settlement drains the owner.
189+ function test_pollFunds_idempotentWithinPartAfterSettlement () public {
197190 (, bytes32 ctx , bytes32 id ) = _setupSchedule ();
198191 vm.warp (_t0 (ctx));
199192
200- // The owner already holds an unrelated balance (e.g. funded for another order).
201- deal (address (token0), address (safe1), PART);
202-
203193 poller.pollFunds (id);
204194
205- assertEq (
206- token0.balanceOf (address (safe1)),
207- PART * 2 ,
208- "full part pulled on top of the existing balance "
209- );
210- assertEq (
211- token0.balanceOf (funder),
212- PART * N - PART,
213- "a full part left the funder "
214- );
195+ vm.prank (address (safe1));
196+ assertTrue (token0.transfer (bob.addr, TWAP_PART_AMOUNT), "part settled " );
197+ assertEq (token0.balanceOf (address (safe1)), 0 , "part settled " );
198+
199+ poller.pollFunds (id); // no-op: this part has already been funded
200+
201+ assertEq (token0.balanceOf (address (safe1)), 0 , "next part not funded early " );
202+ assertEq (token0.balanceOf (funder), TWAP_PART_AMOUNT * N - TWAP_PART_AMOUNT, "no extra pull " );
215203 }
216204
217- /// @dev Repeated calls for the same part are a no-op (guarded by the order digest) .
218- function test_pollFunds_idempotentWithinPart () public {
205+ /// @dev A handler returning A, then B, then A cannot refund A, even after schedule registration .
206+ function test_pollFunds_doesNotRefundEarlierDigestAfterReregister () public {
219207 (, bytes32 ctx , bytes32 id ) = _setupSchedule ();
220208 vm.warp (_t0 (ctx));
221209
210+ bytes memory staticInput = abi.encode (_bundle ());
211+ bytes memory handlerCall = abi.encodeCall (
212+ IConditionalOrderGenerator.getTradeableOrder, (address (safe1), address (poller), ctx, staticInput, bytes ("" ))
213+ );
214+ GPv2Order.Data memory orderA =
215+ twap.getTradeableOrder (address (safe1), address (poller), ctx, staticInput, bytes ("" ));
216+ GPv2Order.Data memory orderB = abi.decode (abi.encode (orderA), (GPv2Order.Data));
217+ orderB.appData = keccak256 ("second valid order " );
218+
219+ vm.mockCall (address (twap), handlerCall, abi.encode (orderA));
222220 poller.pollFunds (id);
223- poller.pollFunds (id); // no-op: this part has already been funded
221+ vm.prank (address (safe1));
222+ assertTrue (token0.transfer (bob.addr, TWAP_PART_AMOUNT), "first order settled " );
224223
224+ vm.clearMockedCalls ();
225+ vm.mockCall (address (twap), handlerCall, abi.encode (orderB));
226+ poller.pollFunds (id);
227+ vm.prank (address (safe1));
228+ assertTrue (token0.transfer (bob.addr, TWAP_PART_AMOUNT), "second order settled " );
229+
230+ vm.prank (funder);
225231 assertEq (
226- token0.balanceOf (address (safe1)),
227- PART,
228- "still exactly one part "
232+ poller.register (
233+ ComposableCowPoller.Schedule ({
234+ handler: IConditionalOrderGenerator (address (twap)),
235+ funder: funder,
236+ owner: address (safe1),
237+ salt: SALT,
238+ staticInput: staticInput
239+ })
240+ ),
241+ id,
242+ "same schedule id "
229243 );
230- assertEq (token0.balanceOf (funder), PART * N - PART, "no extra pull " );
244+
245+ vm.clearMockedCalls ();
246+ vm.mockCall (address (twap), handlerCall, abi.encode (orderA));
247+ poller.pollFunds (id);
248+
249+ assertEq (token0.balanceOf (address (safe1)), 0 , "first order not funded twice " );
250+ assertEq (token0.balanceOf (funder), TWAP_PART_AMOUNT, "only two distinct orders funded " );
251+ assertTrue (poller.funded (id, GPv2Order.hash (orderA, composableCow.domainSeparator ())));
252+ assertTrue (poller.funded (id, GPv2Order.hash (orderB, composableCow.domainSeparator ())));
231253 }
232254
233255 /// @dev A failed ERC-20 transfer must not mark this part as funded.
@@ -237,19 +259,12 @@ contract ComposableCowPollerTest is BaseComposableCoWTest {
237259
238260 vm.mockCall (
239261 address (token0),
240- abi.encodeWithSelector (
241- token0.transferFrom.selector ,
242- funder,
243- address (safe1),
244- PART
245- ),
262+ abi.encodeWithSelector (token0.transferFrom.selector , funder, address (safe1), TWAP_PART_AMOUNT),
246263 abi.encode (false )
247264 );
248265
249266 vm.expectRevert (bytes ("GPv2: failed transferFrom " ));
250267 poller.pollFunds (id);
251-
252- assertEq (poller.lastFunded (id), bytes32 (0 ), "failed pull is not recorded " );
253268 }
254269
255270 /// @dev The headline flow: each part is funded JIT and the owner holds nothing in between.
@@ -260,61 +275,22 @@ contract ComposableCowPollerTest is BaseComposableCoWTest {
260275 for (uint256 part = 0 ; part < N; part++ ) {
261276 vm.warp (t0 + part * FREQ);
262277
263- assertEq (
264- token0.balanceOf (address (safe1)),
265- 0 ,
266- "owner empty before part "
267- );
278+ assertEq (token0.balanceOf (address (safe1)), 0 , "owner empty before part " );
268279 poller.pollFunds (id);
269- assertEq (token0.balanceOf (address (safe1)), PART , "part funded " );
280+ assertEq (token0.balanceOf (address (safe1)), TWAP_PART_AMOUNT , "part funded " );
270281
271282 // Simulate the part settling: the owner's balance is consumed.
272283 vm.prank (address (safe1));
273- token0.transfer (bob.addr, PART );
284+ assertTrue ( token0.transfer (bob.addr, TWAP_PART_AMOUNT), " part settled " );
274285
275286 assertEq (
276287 token0.balanceOf (funder),
277- PART * N - PART * (part + 1 ),
288+ TWAP_PART_AMOUNT * N - TWAP_PART_AMOUNT * (part + 1 ),
278289 "one part funded per window "
279290 );
280291 }
281292 }
282293
283- /// @dev The anti-premature-execution guard: once a part is funded, the *next* part's funds
284- /// cannot be pulled until time advances into its window — even after the part settles and
285- /// drains the owner. Without the per-order guard, the drained owner would be refilled
286- /// immediately and that balance would be sold as the next part, a full interval early.
287- function test_pollFunds_cannotFundFuturePartEarly () public {
288- (, bytes32 ctx , bytes32 id ) = _setupSchedule ();
289- vm.warp (_t0 (ctx)); // part 0 window
290-
291- poller.pollFunds (id);
292- assertEq (token0.balanceOf (address (safe1)), PART, "part 0 funded " );
293-
294- // Simulate the part settling: the owner's balance is consumed.
295- vm.prank (address (safe1));
296- token0.transfer (bob.addr, PART);
297- assertEq (
298- token0.balanceOf (address (safe1)),
299- 0 ,
300- "owner drained by the fill "
301- );
302-
303- // Still inside part 0's window: a fresh pull must NOT refill.
304- poller.pollFunds (id);
305-
306- assertEq (
307- token0.balanceOf (address (safe1)),
308- 0 ,
309- "next part not funded early "
310- );
311- assertEq (
312- token0.balanceOf (funder),
313- PART * N - PART,
314- "exactly one part ever left the funder "
315- );
316- }
317-
318294 /// @dev The pull is bounded to the schedule window: after it ends, `getTradeableOrder` reverts.
319295 function test_pollFunds_RevertWhen_scheduleEnded () public {
320296 (, bytes32 ctx , bytes32 id ) = _setupSchedule ();
0 commit comments