Skip to content

Commit 3538e4f

Browse files
committed
feat: topUp for just-in-time order funding
1 parent 8fdcbe1 commit 3538e4f

6 files changed

Lines changed: 262 additions & 11 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ Fortunately, when using Safe, it is possible to batch together all the above cal
133133
| `TradeAboveThreshold` | [0x812308712a6d1367f437e1c1e4af85c854e1e9f6](https://etherscan.io/address/0x812308712a6d1367f437e1c1e4af85c854e1e9f6) | [0x812308712a6d1367f437e1c1e4af85c854e1e9f6](https://gnosisscan.io/address/0x812308712a6d1367f437e1c1e4af85c854e1e9f6) | [0x812308712a6d1367f437e1c1e4af85c854e1e9f6](https://sepolia.etherscan.io/address/0x812308712a6d1367f437e1c1e4af85c854e1e9f6) | [0x812308712a6d1367f437e1c1e4af85c854e1e9f6](https://arbiscan.io/address/0x812308712a6d1367f437e1c1e4af85c854e1e9f6) | [0x812308712a6d1367f437e1c1e4af85c854e1e9f6](https://basescan.org/address/0x812308712a6d1367f437e1c1e4af85c854e1e9f6) | [0x812308712a6d1367f437e1c1e4af85c854e1e9f6](https://bscscan.com/address/0x812308712a6d1367f437e1c1e4af85c854e1e9f6) | [0x812308712a6d1367f437e1c1e4af85c854e1e9f6](https://snowscan.xyz/address/0x812308712a6d1367f437e1c1e4af85c854e1e9f6) | [0x812308712a6d1367f437e1c1e4af85c854e1e9f6](https://optimistic.etherscan.io/address/0x812308712a6d1367f437e1c1e4af85c854e1e9f6) | [0x812308712a6d1367f437e1c1e4af85c854e1e9f6](https://polygonscan.com/address/0x812308712a6d1367f437e1c1e4af85c854e1e9f6) | [0x812308712a6d1367f437e1c1e4af85c854e1e9f6](https://explorer.lens.xyz/address/0x812308712a6d1367f437e1c1e4af85c854e1e9f6) |
134134
| `StopLoss` | [0x412c36e5011cd2517016d243a2dfb37f73a242e7](https://etherscan.io/address/0x412c36e5011cd2517016d243a2dfb37f73a242e7) | [0x412c36e5011cd2517016d243a2dfb37f73a242e7](https://gnosisscan.io/address/0x412c36e5011cd2517016d243a2dfb37f73a242e7) | [0x412c36e5011cd2517016d243a2dfb37f73a242e7](https://sepolia.etherscan.io/address/0x412c36e5011cd2517016d243a2dfb37f73a242e7) | [0x412c36e5011cd2517016d243a2dfb37f73a242e7](https://arbiscan.io/address/0x412c36e5011cd2517016d243a2dfb37f73a242e7) | [0x412c36e5011cd2517016d243a2dfb37f73a242e7](https://basescan.org/address/0x412c36e5011cd2517016d243a2dfb37f73a242e7) | [0x412c36e5011cd2517016d243a2dfb37f73a242e7](https://bscscan.com/address/0x412c36e5011cd2517016d243a2dfb37f73a242e7) | [0x412c36e5011cd2517016d243a2dfb37f73a242e7](https://snowscan.xyz/address/0x412c36e5011cd2517016d243a2dfb37f73a242e7) | [0x412c36e5011cd2517016d243a2dfb37f73a242e7](https://optimistic.etherscan.io/address/0x412c36e5011cd2517016d243a2dfb37f73a242e7) | [0x412c36e5011cd2517016d243a2dfb37f73a242e7](https://polygonscan.com/address/0x412c36e5011cd2517016d243a2dfb37f73a242e7) | [0x412c36e5011cd2517016d243a2dfb37f73a242e7](https://explorer.lens.xyz/address/0x412c36e5011cd2517016d243a2dfb37f73a242e7) |
135135
| `CurrentBlockTimestampFactory` | [0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc](https://etherscan.io/address/0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc) | [0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc](https://gnosisscan.io/address/0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc) | [0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc](https://sepolia.etherscan.io/address/0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc) | [0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc](https://arbiscan.io/address/0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc) | [0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc](https://basescan.org/address/0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc) | [0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc](https://bscscan.com/address/0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc) | [0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc](https://snowscan.xyz/address/0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc) | [0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc](https://optimistic.etherscan.io/address/0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc) | [0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc](https://polygonscan.com/address/0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc) | [0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc](https://explorer.lens.xyz/address/0x52eD56Da04309Aca4c3FECC595298d80C2f16BAc) |
136+
| `ComposableCowPoller` | - | [0xA360eE11eD0d2025604518CF4B8F6e6CB76C7Df7](https://gnosisscan.io/address/0xA360eE11eD0d2025604518CF4B8F6e6CB76C7Df7) | - | - | - | - | - | - | - | - |
136137

137138
#### Audits
138139

script/deploy_AnvilStack.s.sol

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {TWAP} from "../src/types/twap/TWAP.sol";
2525
import {GoodAfterTime} from "../src/types/GoodAfterTime.sol";
2626
import {PerpetualStableSwap} from "../src/types/PerpetualStableSwap.sol";
2727
import {TradeAboveThreshold} from "../src/types/TradeAboveThreshold.sol";
28+
import {ComposableCowPoller} from "../src/types/ComposableCowPoller.sol";
2829

2930
contract DeployAnvilStack is Script {
3031
// --- constants
@@ -65,11 +66,14 @@ contract DeployAnvilStack is Script {
6566
new GoodAfterTime();
6667
new PerpetualStableSwap();
6768
new TradeAboveThreshold();
69+
ComposableCowPoller poller = new ComposableCowPoller(composableCow);
6870

6971
vm.stopBroadcast();
7072

7173
console.log("Safe address");
7274
console.logAddress(address(proxy));
75+
console.log("ComposableCowPoller address");
76+
console.logAddress(address(poller));
7377
}
7478

7579
function deploySafe(address owner) internal returns (SafeProxy proxy) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// SPDX-License-Identifier: GPL-3.0
2+
pragma solidity >=0.8.0 <0.9.0;
3+
4+
import {Script, console} from "forge-std/Script.sol";
5+
6+
import {ComposableCoW} from "../src/ComposableCoW.sol";
7+
import {ComposableCowPoller} from "../src/types/ComposableCowPoller.sol";
8+
9+
contract DeployComposableCowPoller is Script {
10+
function run() external {
11+
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
12+
address composableCow = vm.envAddress("COMPOSABLE_COW");
13+
14+
vm.startBroadcast(deployerPrivateKey);
15+
16+
ComposableCowPoller poller = new ComposableCowPoller(ComposableCoW(composableCow));
17+
18+
vm.stopBroadcast();
19+
20+
console.log("ComposableCowPoller address");
21+
console.logAddress(address(poller));
22+
}
23+
}

script/deploy_ProdStack.s.sol

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {GoodAfterTime} from "../src/types/GoodAfterTime.sol";
1515
import {PerpetualStableSwap} from "../src/types/PerpetualStableSwap.sol";
1616
import {TradeAboveThreshold} from "../src/types/TradeAboveThreshold.sol";
1717
import {StopLoss} from "../src/types/StopLoss.sol";
18+
import {ComposableCowPoller} from "../src/types/ComposableCowPoller.sol";
1819

1920
// Value factories
2021
import {CurrentBlockTimestampFactory} from "../src/value_factories/CurrentBlockTimestampFactory.sol";
@@ -39,6 +40,9 @@ contract DeployProdStack is Script {
3940
new TradeAboveThreshold{salt: "v1.0.0"}();
4041
new StopLoss{salt: "v1.0.0"}();
4142

43+
// Deploy the just-in-time funding poller
44+
new ComposableCowPoller{salt: "v1.0.0"}(composableCow);
45+
4246
// Deploy value factories
4347
new CurrentBlockTimestampFactory{salt: "v1.0.0"}();
4448
}

src/types/ComposableCowPoller.sol

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,56 @@
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
/// @notice Pulls exactly the current discrete order's `sellAmount` from a funds source into the
811
/// order owner, immediately before that order settles. Designed to be invoked from a CoW
912
/// Protocol pre-hook so capital can sit in the user's EOA (or a treasury) between parts
1013
/// instead of being locked up front for the whole order (e.g. the full notional of a
1114
/// long-running TWAP / DCA).
12-
/// @dev This is the generic "ComposableCowPoller". It is not tied to any single order type; the
15+
/// @dev This is the generic "ComposableCowPoller". It is not tied to any single order type. The
1316
/// handler to poll is supplied per-schedule, and the amount / sell token / validity window
14-
/// are all read from that handler's own `getTradeableOrder`.
17+
/// are all read from that handler's own `getTradeableOrder`, so the poller carries no
18+
/// order-type-specific logic of its own.
19+
///
20+
/// Self-funding pre-hooks. A schedule is keyed by an `id` that is *independent of the
21+
/// order's `appData`* (`id = scheduleId(funder, handler, owner, salt)`). This is what makes
22+
/// `topUp` usable from the order's own appData: the conditional order's `ctx` contains its
23+
/// `appData` hash, so a hook that referenced `ctx` (or `staticInput`, or the order digest)
24+
/// could never be embedded in that same `appData` — it would be circular. By keying on
25+
/// appData-independent fields and re-deriving `ctx` on-chain from the stored params, the
26+
/// hook calldata `topUp(id)` no longer depends on `appData`, breaking the cycle. Every
27+
/// discrete part inherits the parent order's `appData`, so each part funds itself JIT.
1528
///
16-
/// A schedule is keyed by an `id` that is *independent of the order's `appData`*
17-
/// (`id = scheduleId(funder, handler, owner, salt)`). This is what makes the funding hook
18-
/// usable from the order's own appData: the conditional order's `ctx` contains its `appData`
19-
/// hash, so a hook that referenced `ctx` (or `staticInput`, or the order digest) could never
20-
/// be embedded in that same `appData` — it would be circular. Keying on appData-independent
21-
/// fields breaks the cycle.
29+
/// Security model. `topUp` is intentionally permissionless: safety comes from constraining
30+
/// *what* a call can do, never *who* makes it:
31+
///
32+
/// 1. The order must still be authorised: `ComposableCoW.singleOrders(owner, ctx)` must be
33+
/// true, where `ctx` is re-derived on-chain from the stored params. `ComposableCoW.remove`
34+
/// flips it false, which disables the poller for free.
35+
/// 2. The amount, the sell token and the validity window are all taken from the handler's
36+
/// own `getTradeableOrder` — never from caller-supplied arguments. Outside an active
37+
/// window `getTradeableOrder` reverts, so funds can never be pulled while no discrete
38+
/// order is tradeable.
39+
/// 3. The destination (`owner`) is read from the schedule the funder registered, so a pull
40+
/// can only ever move funds to the owner's own account.
41+
/// 4. Each discrete order is funded at most once, keyed by its unique order digest. This is
42+
/// the anti-"premature execution" guard: because `getTradeableOrder` only ever yields the
43+
/// *current* order, and a given digest is never re-funded, the *next* part's funds can
44+
/// never be pulled before its own window opens.
45+
/// 5. The top-up is balance-capped to the current order, so repeated calls are idempotent
46+
/// and the owner never holds more than one order's worth at a time.
47+
/// 6. The schedule `id` is namespaced by `funder`, so nobody can squat or overwrite another
48+
/// funder's schedule.
2249
contract ComposableCowPoller {
50+
/// @dev The ComposableCoW instance that authorises the order. Also the source of the domain
51+
/// separator used to compute the order digest, and of `hash(params)` used to derive `ctx`.
52+
ComposableCoW public immutable composableCow;
53+
2354
struct Schedule {
2455
IConditionalOrderGenerator handler; // the conditional-order handler to poll (e.g. the TWAP type)
2556
address funder; // source of funds (the EOA in the TWAP-for-EOA flow); the only registrant
@@ -33,12 +64,24 @@ contract ComposableCowPoller {
3364
/// that same `appData`. Every field of `id` is appData-independent.
3465
mapping(bytes32 => Schedule) public schedules;
3566

67+
/// @dev `id => digest` of the discrete order most recently funded. Each part has a distinct
68+
/// digest, so this enforces "fund each discrete order at most once" and prevents the next
69+
/// part from being funded before its own window opens.
70+
mapping(bytes32 => bytes32) public lastFunded;
71+
3672
error OnlyFunder();
73+
error NoSchedule();
74+
error OrderNotLive();
3775

3876
event ScheduleRegistered(bytes32 indexed id, address indexed owner, address indexed funder);
77+
event Pulled(bytes32 indexed id, bytes32 orderDigest, uint256 amount);
78+
79+
constructor(ComposableCoW _composableCow) {
80+
composableCow = _composableCow;
81+
}
3982

4083
/// @notice Deterministic, appData-independent schedule key. Integrators compute this off-chain
41-
/// to embed the funding hook in the order's appData, and it is re-derived here on `register`.
84+
/// to embed `topUp(id)` in the order's appData, and it is re-derived here on `register`.
4285
function scheduleId(address funder, IConditionalOrderGenerator handler, address owner, bytes32 salt)
4386
public
4487
pure
@@ -54,6 +97,51 @@ contract ComposableCowPoller {
5497
if (msg.sender != schedule.funder) revert OnlyFunder();
5598
id = scheduleId(schedule.funder, schedule.handler, schedule.owner, schedule.salt);
5699
schedules[id] = schedule;
100+
// A re-registration starts a fresh funding history for this id.
101+
delete lastFunded[id];
57102
emit ScheduleRegistered(id, schedule.owner, schedule.funder);
58103
}
104+
105+
/// @notice Fund the order owner with the current discrete order's `sellAmount`. Intended as a
106+
/// pre-hook embedded in the order's own appData, but permissionless: the guards below
107+
/// make the caller irrelevant.
108+
/// @dev Idempotent within an order, and a no-op once that order has been funded. Reverts
109+
/// outside an active window (delegated to the handler's `getTradeableOrder`).
110+
function topUp(bytes32 id) external {
111+
Schedule memory schedule = schedules[id];
112+
if (schedule.funder == address(0)) revert NoSchedule();
113+
114+
// Re-derive `ctx` on-chain from the stored params. `id` is appData-independent, so the hook
115+
// calldata `topUp(id)` does not depend on `appData` — that is what breaks the cycle that
116+
// would otherwise prevent embedding this call in the order's own appData.
117+
bytes32 ctx = composableCow.hash(
118+
IConditionalOrder.ConditionalOrderParams({
119+
handler: schedule.handler,
120+
salt: schedule.salt,
121+
staticInput: schedule.staticInput
122+
})
123+
);
124+
125+
// The order must still be authorised. `remove` flips this false, disabling the poller.
126+
if (!composableCow.singleOrders(schedule.owner, ctx)) revert OrderNotLive();
127+
128+
// Reuse the handler's own logic: this reverts outside the active window and yields the
129+
// exact discrete order. The amount and token come from here, so the caller controls
130+
// nothing that could change how much moves or in which asset.
131+
GPv2Order.Data memory order =
132+
schedule.handler.getTradeableOrder(schedule.owner, address(this), ctx, schedule.staticInput, bytes(""));
133+
134+
// Fund each discrete order at most once, keyed by its unique digest.
135+
bytes32 digest = GPv2Order.hash(order, composableCow.domainSeparator());
136+
if (digest == lastFunded[id]) return;
137+
lastFunded[id] = digest;
138+
139+
// Top up the owner to exactly the current order. The destination is fixed by the schedule.
140+
uint256 balance = order.sellToken.balanceOf(schedule.owner);
141+
if (balance < order.sellAmount) {
142+
uint256 deficit = order.sellAmount - balance;
143+
order.sellToken.transferFrom(schedule.funder, schedule.owner, deficit);
144+
emit Pulled(id, digest, deficit);
145+
}
146+
}
59147
}

0 commit comments

Comments
 (0)