Skip to content

Commit 8da1bc9

Browse files
committed
fix: harden poller replay protection (#125)
Track every funded digest across schedule updates, document salt construction, and remove redundant funding tests.
1 parent e2c57be commit 8da1bc9

3 files changed

Lines changed: 85 additions & 108 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ To create a TWAP order:
103103

104104
1. ABI-Encode the `IConditionalOrder.ConditionalOrderParams` struct with:
105105
- `handler`: set to the `TWAP` smart contract deployment.
106-
- `salt`: set to a unique value.
106+
- `salt`: use a value unique to the user and logical order. For poller schedules, a good default is
107+
the hash of the order-defining `staticInput` values with any `appData` field set to zero.
107108
- `staticInput`: the ABI-encoded `TWAP.Data` struct.
108109
2. Use the `struct` from (1) as either a Merkle leaf, or with `ComposableCoW.create` to create a single conditional order.
109110
3. Approve `GPv2VaultRelayer` to trade `n x partSellAmount` of the safe's `sellToken` tokens (in the example above, `GPv2VaultRelayer` would receive approval for spending 12,000,000 DAI tokens).

src/types/ComposableCowPoller.sol

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {IConditionalOrder, IConditionalOrderGenerator} from "src/interfaces/ICon
1111
contract ComposableCowPoller {
1212
using GPv2SafeERC20 for IERC20;
1313

14-
ComposableCoW public immutable composableCow;
14+
/// @dev `ComposableCoW` stores the settlement domain separator supplied at deployment.
15+
ComposableCoW public immutable COMPOSABLE_COW;
1516

1617
/// @notice Parameters for a JIT funding schedule.
1718
/// @dev A schedule is uniquely identified by its funder, handler, owner, and salt.
@@ -25,8 +26,8 @@ contract ComposableCowPoller {
2526
/// @dev It can be an EOA or contract and may be the same address as `funder`.
2627
address owner;
2728
/// @notice A user-controlled namespace for this schedule.
28-
/// @dev Use a value unique to the user and order. Deriving it from order-defining static-input values,
29-
/// excluding appData, is recommended.
29+
/// @dev Keep it unique to the user and logical order. A good default is the hash of the
30+
/// order-defining static-input values with any appData field set to zero.
3031
bytes32 salt;
3132
/// @notice The static input passed to the handler when it generates an order.
3233
bytes staticInput;
@@ -35,8 +36,8 @@ contract ComposableCowPoller {
3536
/// @dev Keyed by `id == scheduleId(schedule)`, which excludes the order's `appData`.
3637
mapping(bytes32 => Schedule) public schedules;
3738

38-
/// @dev `id => digest` of the order last funded, so each order is funded at most once.
39-
mapping(bytes32 => bytes32) public lastFunded;
39+
/// @dev `id => digest => funded`. History survives schedule updates so an old order cannot be replayed.
40+
mapping(bytes32 => mapping(bytes32 => bool)) public funded;
4041

4142
/// @notice Thrown when someone other than the schedule funder registers or updates a schedule.
4243
error OnlyFunder();
@@ -51,12 +52,13 @@ contract ComposableCowPoller {
5152
event Pulled(bytes32 indexed id, bytes32 orderDigest, uint256 amount);
5253

5354
constructor(ComposableCoW _composableCow) {
54-
composableCow = _composableCow;
55+
COMPOSABLE_COW = _composableCow;
5556
}
5657

5758
/// @notice Computes the deterministic, appData-independent schedule key.
5859
/// @dev `staticInput` is excluded because its appData can depend on this key.
59-
/// Use a different salt for concurrent schedules with the same funder, handler, and owner.
60+
/// Keep the salt unique to the user and logical order. A good default is the hash of the
61+
/// order-defining static-input values with any appData field set to zero.
6062
/// @param schedule The schedule whose identity fields determine the key.
6163
/// @return The schedule key.
6264
function scheduleId(Schedule memory schedule) public pure returns (bytes32) {
@@ -65,14 +67,14 @@ contract ComposableCowPoller {
6567

6668
/// @notice Registers or updates a schedule.
6769
/// @dev Registering the same funder, handler, owner, and salt replaces the stored schedule.
68-
/// Only the funds source may register, and the ID is namespaced by the funder.
70+
/// Only the funds source may register, and the ID is namespaced by the funder. Funding
71+
/// history is preserved across updates; use a new salt for a new logical schedule.
6972
/// @param schedule The schedule to store.
7073
/// @return id The deterministic key of the stored schedule.
7174
function register(Schedule calldata schedule) external returns (bytes32 id) {
7275
if (msg.sender != schedule.funder) revert OnlyFunder();
7376
id = scheduleId(schedule);
7477
schedules[id] = schedule;
75-
delete lastFunded[id]; // reset funding history
7678
emit ScheduleRegistered(id, schedule.owner, schedule.funder);
7779
}
7880

@@ -84,25 +86,23 @@ contract ComposableCowPoller {
8486
if (schedule.funder == address(0)) revert NoSchedule();
8587

8688
// Re-derive `ctx` on-chain, so `pollFunds(id)` stays independent of the order's `appData`.
87-
bytes32 ctx = composableCow.hash(
89+
bytes32 ctx = COMPOSABLE_COW.hash(
8890
IConditionalOrder.ConditionalOrderParams({
89-
handler: schedule.handler,
90-
salt: schedule.salt,
91-
staticInput: schedule.staticInput
91+
handler: schedule.handler, salt: schedule.salt, staticInput: schedule.staticInput
9292
})
9393
);
9494

9595
// The order must still be authorised; `remove` disables the poller.
96-
if (!composableCow.singleOrders(schedule.owner, ctx)) revert OrderNotLive();
96+
if (!COMPOSABLE_COW.singleOrders(schedule.owner, ctx)) revert OrderNotLive();
9797

9898
// The handler yields the current order and reverts outside its window.
9999
GPv2Order.Data memory order =
100100
schedule.handler.getTradeableOrder(schedule.owner, address(this), ctx, schedule.staticInput, bytes(""));
101101

102-
// Fund each order at most once.
103-
bytes32 digest = GPv2Order.hash(order, composableCow.domainSeparator());
104-
if (digest == lastFunded[id]) return;
105-
lastFunded[id] = digest;
102+
// `ComposableCoW` exposes the settlement domain separator it received at deployment.
103+
bytes32 digest = GPv2Order.hash(order, COMPOSABLE_COW.domainSeparator());
104+
if (funded[id][digest]) return;
105+
funded[id][digest] = true;
106106

107107
order.sellToken.safeTransferFrom(schedule.funder, schedule.owner, order.sellAmount);
108108
emit Pulled(id, digest, order.sellAmount);

test/ComposableCowPoller.t.sol

Lines changed: 65 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// SPDX-License-Identifier: GPL-3.0
22
pragma solidity >=0.8.0 <0.9.0;
33

4+
import {GPv2Order} from "cowprotocol/contracts/libraries/GPv2Order.sol";
5+
46
import {IConditionalOrder, IValueFactory, BaseComposableCoWTest} from "test/ComposableCoW.base.t.sol";
57

68
import {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

Comments
 (0)