feat: pollFunds for just-in-time order funding#125
Conversation
8fdcbe1 to
4e308b0
Compare
3538e4f to
7a6dc89
Compare
7a6dc89 to
71c5297
Compare
71c5297 to
c4f5a50
Compare
adb0365 to
70ba19a
Compare
4e308b0 to
6966303
Compare
70ba19a to
c71ed55
Compare
6966303 to
04c1bde
Compare
c71ed55 to
f1988b4
Compare
04c1bde to
bf350d1
Compare
f1988b4 to
d07ce51
Compare
| if (msg.sender != schedule.funder) revert OnlyFunder(); | ||
| id = scheduleId(schedule.funder, schedule.handler, schedule.owner, schedule.salt); | ||
| schedules[id] = schedule; | ||
| delete lastFunded[id]; // reset funding history |
There was a problem hiding this comment.
Every successful registration clears lastFunded[id].
A realistic sequence is:
- Alice creates a 10-day TWAP and gives the poller approval for 100 USDC.
- On day 3, the poller pulls the intended 10 USDC into Alice's
CowShedand records that pull. - Alice signs and sends another
registertransaction with the same ID. - The contract accepts it and runs
delete lastFunded[id]. - A call to
pollFundstransfers another 10 USDC intoCowShed, because it doesn't know about the old order anymore.
This is only weird UX, not an issue, but something to be documented, perhaps even reject overriding existing IDs to not disrupt ongoing orders, unless this was done for a reason.
There was a problem hiding this comment.
If the user creates a new TWAP schedule without changing any of hte other parameters (salt, owner, funder, and handler), then they are creating a new TWAP schedule, and technically the old TWAP schedule, while still active, would not be able to receive new funds
Since there is a new TWAP schedule, that would mean that this new TWAP schedule would have its own funds pulling schedule, so it seems reasonable to me that
At this point, I think its OK to leave the existing functionality, and instead advise the user that salt shouldn't be an arbitrary value only used to prevent duplication; it should be the hash of the staticInput, with any appData related fields set to 0. This more or less completely prevents the collisions we are concerned about.
kaze-cow
left a comment
There was a problem hiding this comment.
im getting a lot of compilation warnings when I attempt to run forge build here in ComposableCowPoller.sol
I ran coverage tests and happy to confirm the ComposableCowPoller is fully covered
|------------------------------------------------------+------------------+------------------+----------------+-----------------|
| src/types/ComposableCowPoller.sol | 100.00% (21/21) | 100.00% (24/24) | 100.00% (4/4) | 100.00% (4/4) |
|------------------------------------------------------+------------------+------------------+----------------+-----------------|
Finally, as this is the last PR, I will confirm, I wasn't able to find any significant security issue with not including the staticInput in the schedule hashing; worst comes to worst, the tokens are sent from the user's wallet to their CoWShed unexpectedly. the funder has to set the schedule for any funds that can leave their wallet, so it seems safe. I would, however, recommend we augment salt to be renamed something like staticInputNormalizedHash or so as this would mostly prevent any possible issues with TWAP schedules or similar being refreshed, and the poller overwriting instead of creating a new schedule accordingly.
| ); | ||
|
|
||
| // The order must still be authorised; `remove` disables the poller. | ||
| if (!composableCow.singleOrders(schedule.owner, ctx)) revert OrderNotLive(); |
There was a problem hiding this comment.
| if (!composableCow.singleOrders(schedule.owner, ctx)) revert OrderNotLive(); | |
| require(composableCow.singleOrders(schedule.owner, ctx), OrderNotLive()); |
There was a problem hiding this comment.
technically its also possible that the order may be authorized through merkle hash, though I assume this functionality just hasn't been used so its ok to ignore this.
There was a problem hiding this comment.
While I prefer this style of reverts, this is not a standard adopted in any CoW repo IIRC.
| contract ComposableCowPoller { | ||
| using GPv2SafeERC20 for IERC20; | ||
|
|
||
| ComposableCoW public immutable composableCow; |
There was a problem hiding this comment.
This should be COMPOSABLE_COW due to solidity conventions of immutables
| schedule.handler.getTradeableOrder(schedule.owner, address(this), ctx, schedule.staticInput, bytes("")); | ||
|
|
||
| // Fund each order at most once. | ||
| bytes32 digest = GPv2Order.hash(order, composableCow.domainSeparator()); |
There was a problem hiding this comment.
its weird here to use the domain separator of composableCow. Why not use the domain hash of the settlement contract instead? It can be included as an additional immutable property. That way, the order hash will match up with the one actually on the settlement contract.
There was a problem hiding this comment.
ComposableCoW.domainSeparator() already is the settlement domain separator (see here)
| mapping(bytes32 => Schedule) public schedules; | ||
|
|
||
| /// @dev `id => digest` of the order last funded, so each order is funded at most once. | ||
| mapping(bytes32 => bytes32) public lastFunded; |
There was a problem hiding this comment.
with our current pragmatic orders, I don't believe there is any reason why this strategy of only tracking the order last funded would be problematic. However, if a pragmatic order's next order could somehow be controlled or set by a value/field that a solver can manipulate, a single order could be reused/pulled multiple times by switching back and forth between two valid getTradableOrders
There was a problem hiding this comment.
Good point, this is something I've also thought about and I've introduced a fix for this with the new mapping:
mapping(bytes32 id => mapping(bytes32 digest => bool isFunded)) funded
what this would allow is to keep track of older legs and if they had been funded, preventing any A -> B -> A (older legs) being replayed.
| /// @dev A single poll moves exactly the current part into the owner. | ||
| function test_pollFunds_fundsCurrentPart() public { | ||
| (, bytes32 ctx, bytes32 id) = _setupSchedule(); | ||
| vm.warp(_t0(ctx)); | ||
|
|
||
| assertEq( | ||
| token0.balanceOf(address(safe1)), | ||
| 0, | ||
| "owner empty before pull" | ||
| ); | ||
|
|
||
| poller.pollFunds(id); | ||
|
|
||
| assertEq( | ||
| token0.balanceOf(address(safe1)), | ||
| PART, | ||
| "owner funded with exactly one part" | ||
| ); | ||
| assertEq( | ||
| token0.balanceOf(funder), | ||
| PART * N - PART, | ||
| "exactly one part left the funder" | ||
| ); | ||
| } |
There was a problem hiding this comment.
this test is superfluous with the following test_pollFunds_movesFullAmountUnconditionally
| /// @notice Move the current order's `sellAmount` from the funder to the owner. Permissionless. | ||
| /// The full amount always moves (no balance check), so one owner can serve several | ||
| /// concurrent orders. | ||
| function pollFunds(bytes32 id) external { |
There was a problem hiding this comment.
this function could return false or true depending upon whether funds were ultimately transferred or not. This would mostly make the tests a bit more clear and offer a bit more surface in the off chance that a smart contract wants to use this contract and know if funds were transferred or not without relying on balance checks.
There was a problem hiding this comment.
This is useful only if we have callers that need the result. I’d keep the current interface until that's the case.
| vm.expectRevert(bytes("GPv2: failed transferFrom")); | ||
| poller.pollFunds(id); | ||
|
|
||
| assertEq(poller.lastFunded(id), bytes32(0), "failed pull is not recorded"); |
There was a problem hiding this comment.
technically this check is superfluous if the transaction is already known to have reverted
| /// cannot be pulled until time advances into its window — even after the part settles and | ||
| /// drains the owner. Without the per-order guard, the drained owner would be refilled | ||
| /// immediately and that balance would be sold as the next part, a full interval early. | ||
| function test_pollFunds_cannotFundFuturePartEarly() public { |
There was a problem hiding this comment.
this test is superfluous with test_pollFunds_idempotentWithinPart. If you want to drive this part home, then would be better to test this in test_pollFunds_fundsEachPartAcrossSchedule by calling pollFunds twice
| if (msg.sender != schedule.funder) revert OnlyFunder(); | ||
| id = scheduleId(schedule.funder, schedule.handler, schedule.owner, schedule.salt); | ||
| schedules[id] = schedule; | ||
| delete lastFunded[id]; // reset funding history |
There was a problem hiding this comment.
If the user creates a new TWAP schedule without changing any of hte other parameters (salt, owner, funder, and handler), then they are creating a new TWAP schedule, and technically the old TWAP schedule, while still active, would not be able to receive new funds
Since there is a new TWAP schedule, that would mean that this new TWAP schedule would have its own funds pulling schedule, so it seems reasonable to me that
At this point, I think its OK to leave the existing functionality, and instead advise the user that salt shouldn't be an arbitrary value only used to prevent duplication; it should be the hash of the staticInput, with any appData related fields set to 0. This more or less completely prevents the collisions we are concerned about.
21fe323 to
45755b8
Compare
Track every funded digest across schedule updates, document salt construction, and remove redundant funding tests.
0359eff to
8da1bc9
Compare
Track every funded digest across schedule updates, document salt construction, and remove redundant funding tests.
8da1bc9 to
3fe9374
Compare
Part of #121. Follows on #124
What
This is the PR that adding the main feature.
Adds the just-in-time funding entry point (
pollFunds). This is the function that will be called by the hooks to poll the funds.The method will do a series of validations:
orderUid. This is how we can reuse the logic from the handlers to be used for the polling too. For example, in TWAP makes sure we don't pull twice for the same leg.Topup vs pollFunds
Initially in my first draft I implemented a method to top up the account (adding whatever balance that is missing in order to be able to fullfull the
order.sellAmount.I decided to rename to
pollFundsto move the funds unconditionally (don't check the balance. This way, we can reuse the same trader contract for more concurrent orders, and we don't run into race conditions where some order uses the funds of some other.Test plan
forge test --match-contract ComposableCowPollerTest -vvTest should pass