From b458eec8b3e809aa6d42943792768af2e107d05f Mon Sep 17 00:00:00 2001 From: Anxo <2352112+anxolin@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:40:17 +0100 Subject: [PATCH 1/4] Add JIT-funded TWAP from EOA using ComposableCowPoller Follow-up to the TWAP-from-EOA PoC. Instead of moving the full TWAP sell amount into cow-shed up front, each part is pulled just-in-time from the EOA via the ComposableCowPoller contract, so capital stays in the user's wallet between parts. - New postTwapForEOAWithJitFunds script: a 1-wei sell=buy order carries a gasless post-hook that approves the Vault Relayer and creates the TWAP on cow-shed; the EOA approves the poller for the full sell amount and registers the JIT funding schedule. The watch-tower then pulls each part on demand. - Add a minimal ComposableCowPoller contract helper and its Gnosis address. --- src/const/gnosis.ts | 5 + src/contracts/composable-cow-poller/index.ts | 38 ++ src/index.ts | 4 +- .../postTwapForEOAWithJitFunds.ts | 347 ++++++++++++++++++ 4 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 src/contracts/composable-cow-poller/index.ts create mode 100644 src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts diff --git a/src/const/gnosis.ts b/src/const/gnosis.ts index 5dd296e..bdf46a9 100644 --- a/src/const/gnosis.ts +++ b/src/const/gnosis.ts @@ -1 +1,6 @@ export const GNO_ADDRESS = "0x9C58BAcC331c9aa871AFD802DB6379a98e80CEdb"; + +// ComposableCowPoller: just-in-time funding for composable conditional orders. +// See https://github.com/cowprotocol/composable-cow/pull/116 +export const COMPOSABLE_COW_POLLER_ADDRESS = + "0x3ba140fae6a1688aaddc8ad37ea0f6230d6b6178"; diff --git a/src/contracts/composable-cow-poller/index.ts b/src/contracts/composable-cow-poller/index.ts new file mode 100644 index 0000000..20f17be --- /dev/null +++ b/src/contracts/composable-cow-poller/index.ts @@ -0,0 +1,38 @@ +import { ethers } from "ethers"; + +/** + * Minimal ABI for the `ComposableCowPoller` contract. + * + * It enables just-in-time funding for composable conditional orders: instead of + * locking the whole notional up front, `topUp` pulls exactly the current discrete + * order's `sellAmount` from a funder into the order owner, immediately before that + * order settles. + * + * @see https://github.com/cowprotocol/composable-cow/pull/116 + */ +const COMPOSABLE_COW_POLLER_ABI = [ + "function composableCow() external view returns (address)", + "function register(bytes32 ctx, (address handler, address funder, address owner, bytes staticInput) schedule) external", + "function revoke(bytes32 ctx) external", + "function topUp(bytes32 ctx) external", + "function schedules(bytes32 ctx) external view returns (address handler, address funder, address owner, bytes staticInput)", + "function lastFunded(bytes32 ctx) external view returns (bytes32)", +] as const; + +export interface PollerSchedule { + /** The conditional-order handler to poll (e.g. the TWAP type). */ + handler: string; + /** Source of funds (the EOA in the TWAP-for-EOA flow); the only registrant. */ + funder: string; + /** Order owner (cow-shed / Safe); the fixed pull destination. */ + owner: string; + /** The order's `staticInput`, passed verbatim to `getTradeableOrder`. */ + staticInput: string; +} + +export function getComposableCowPollerContract( + pollerAddress: string, + signer?: ethers.Signer | ethers.providers.Provider +): ethers.Contract { + return new ethers.Contract(pollerAddress, COMPOSABLE_COW_POLLER_ABI, signer); +} diff --git a/src/index.ts b/src/index.ts index a8cda04..73cb5c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,7 @@ import { run as getEthFlowId } from "./scripts/ethflow/getEthFlowId"; import { run as minimalAppData } from "./scripts/app-data/minimalAppData"; import { run as getIpfsForLegacyDoc } from "./scripts/app-data/getIpfsForLegacyDoc"; import { run as postTwapForEOA } from "./scripts/composable-cow/postTwapForEOA"; +import { run as postTwapForEOAWithJitFunds } from "./scripts/composable-cow/postTwapForEOAWithJitFunds"; dotenv.config(); @@ -86,7 +87,8 @@ const JOBS: (() => Promise)[] = [ // getEthFlowId, // minimalAppData, // getIpfsForLegacyDoc, - postTwapForEOA, + // postTwapForEOA, + postTwapForEOAWithJitFunds, ]; async function main() { diff --git a/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts b/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts new file mode 100644 index 0000000..f27b03e --- /dev/null +++ b/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts @@ -0,0 +1,347 @@ +import { APP_CODE, COW_VAULT_RELAYER_CONTRACT } from "../../const"; +import { COMPOSABLE_COW_POLLER_ADDRESS } from "../../const/gnosis"; + +import { + SupportedChainId, + OrderKind, + TradingSdk, + Twap, + CowShedSdk, + COMPOSABLE_COW_CONTRACT_ADDRESS, + OrderBookApi, +} from "@cowprotocol/cow-sdk"; + +import { MetadataApi } from "@cowprotocol/app-data"; +import { BigNumber, ethers } from "ethers"; +import { confirm, debugStringify, getWallet, printQuote } from "../../utils"; +import { getErc20Contract } from "../../contracts/erc20"; +import { getComposableCowPollerContract } from "../../contracts/composable-cow-poller"; + +const DEFAULT_GAS_LIMIT = 500_000n; + +interface Token { + symbol: string; + address: string; + decimals: number; + contract: ethers.Contract; +} + +const TOKENS = { + twapSellToken: "0xaf204776c7245bF4147c2612BF6e5972Ee483701", // sDAI + twapBuyToken: "0x177127622c4A00F3d409B75571e12cB3c8973d3c", // COW +} as const; + +const PARTS = 2; +const TIME_BETWEEN_PARTS = 300; // seconds + +// The sell=buy order's only purpose is to get the post-hook (which creates the +// TWAP) executed gaslessly via a settlement. +// +// It does NOT move the full TWAP sell amount: This is why no the recipient of the funds is still the EOA. +// as opposed to what src/scripts/composable-cow/postTwapForEOA.ts does +// +// Thanks to JIT funding, each part is pulled from the EOA right before it settles. +const SELL_BUY_ORDER_BUY_AMOUNT = "1"; // 1 wei of sDAI to buy (arrives to the EOA) + +const CHAIN_ID = SupportedChainId.GNOSIS_CHAIN; + +export async function run() { + const wallet = await getWallet(CHAIN_ID); + const eoaTrader = wallet.address as `0x${string}`; + + // Initialize the SDK with the wallet + const sdk = new TradingSdk({ + chainId: CHAIN_ID, + signer: wallet, // Use a signer + appCode: APP_CODE, + }); + + // Get some info about the assets + const { twapSellToken, twapBuyToken } = await getAssetsInfo({ wallet }); + + const sellAmount = ethers.utils.parseUnits("0.2", twapSellToken.decimals); // total TWAP sell amount + const sellAmountFormatted = ethers.utils.formatUnits( + sellAmount, + twapSellToken.decimals, + ); + const sellBuyOrderBuyAmount = BigNumber.from(SELL_BUY_ORDER_BUY_AMOUNT); // 1 wei of sDAI + + const cowShedSdk = new CowShedSdk({ + factoryOptions: { + factoryAddress: "0x4f4350bf2c74aacd508d598a1ba94ef84378793d", + implementationAddress: "0x6773d5aA31A1EAD34127D564D6E258E66254EbDb", + proxyCreationCode: + "0x60a03461009557601f61033d38819003918201601f19168301916001600160401b0383118484101761009957808492604094855283398101031261009557610052602061004b836100ad565b92016100ad565b6080527f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5560405161027b90816100c28239608051818181608b01526101750152f35b5f80fd5b634e487b7160e01b5f52604160045260245ffd5b51906001600160a01b03821682036100955756fe60806040526004361015610018575b3661019757610197565b5f3560e01c8063025b22bc146100375763f851a4400361000e57610116565b346101125760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101125760043573ffffffffffffffffffffffffffffffffffffffff81169081810361011257337f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff160361010d577f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc557fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b5f80a2005b61023d565b5f80fd5b34610112575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261011257602061014e61016c565b73ffffffffffffffffffffffffffffffffffffffff60405191168152f35b33300361010d577f000000000000000000000000000000000000000000000000000000000000000090565b60ff7f68df44b1011761f481358c0f49a711192727fb02c377d697bcb0ea8ff8393ac0541615806101f0575b1561023d577ff92ee8a9000000000000000000000000000000000000000000000000000000005f5260045ffd5b507fc4d66de8000000000000000000000000000000000000000000000000000000007fffffffff000000000000000000000000000000000000000000000000000000005f351614156101c3565b5f807f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc54368280378136915af43d5f803e15610277573d5ff35b3d5ffd", + }, + }); + const cowShed = cowShedSdk.getCowShedAccount(CHAIN_ID, eoaTrader); + console.log("CowShed account:", cowShed); + + // Describe the flow + console.log( + `TWAP sell ${sellAmountFormatted} ${twapSellToken.symbol} for ${twapBuyToken.symbol} in ${PARTS} parts, funded just-in-time. +The setup is done with a gasless sell=buy order with a post-hook: + - Sell=buy order: BUY ${SELL_BUY_ORDER_BUY_AMOUNT} wei of ${twapSellToken.symbol} with ${twapSellToken.symbol} (sell == buy), so the only thing it does is carry the post-hook. + - Post-hook (via cow-shed): approve the Vault Relayer + create the TWAP. Owner of the TWAP is cow-shed (${cowShed}). +The EOA keeps the ${twapSellToken.symbol} and approves the ComposableCowPoller (${COMPOSABLE_COW_POLLER_ADDRESS}). +Before each part settles, the watch-tower calls poller.topUp(ctx), pulling exactly that part's sell amount from the EOA into cow-shed.`, + ); + + // Generate app data for TWAP order + const metadataApi = new MetadataApi(); + const twapAppData = await metadataApi.generateAppDataDoc({ + appCode: APP_CODE, + environment: "prod", + metadata: {}, + }); + const { appDataContent: twapAppDataContent, appDataHex: twapAppDataHex } = + await metadataApi.getAppDataInfo(twapAppData); + + const orderBookApi = new OrderBookApi({ + chainId: CHAIN_ID, + }); + + // Build the TWAP. Owner is cow-shed (the order owner / pull destination); the + // bought tokens are sent to the trader. `startTime` defaults to AT_MINING_TIME + // (t0 = 0), so `createCalldata` uses `createWithContext` with the + // CurrentBlockTimestampFactory, anchoring t0 to the settlement block. This is + // what lets the poller's `getTradeableOrder` resolve the live part. + const twap = Twap.fromData({ + receiver: eoaTrader, + sellAmount: sellAmount, + buyAmount: BigNumber.from(PARTS), // TODO: Get another quote and apply a good slippage + numberOfParts: BigNumber.from(PARTS), + timeBetweenParts: BigNumber.from(TIME_BETWEEN_PARTS), + sellToken: twapSellToken.address, + buyToken: twapBuyToken.address, + appData: twapAppDataHex, + }); + + // `ctx == ComposableCoW.hash(params) == twap.id` is the key both the cabinet + // and the poller schedule are stored under. + const ctx = twap.id; + const { handler, staticInput } = twap.leaf; + + console.log("TWAP context (ctx):", ctx); + console.log("TWAP params for creation of order", { + twapParams: twap.leaf, + twapData: debugStringify(twap.data), + twapAppDataContent: twapAppDataContent, + }); + + console.log("Uploading TWAP app data to API..."); + await orderBookApi.uploadAppData(twapAppDataHex, twapAppDataContent); + + // Post-hook: approve the Vault Relayer (so each part can settle from cow-shed) + // and create the TWAP. Both are fund-less calls, so cow-shed never needs to + // hold the full TWAP sell amount up front. + const approveSellTokenCalldata = + twapSellToken.contract.interface.encodeFunctionData("approve", [ + COW_VAULT_RELAYER_CONTRACT, + ethers.constants.MaxUint256, + ]); + + const deadline = BigInt(Math.ceil(Date.now() / 1000)) + 1800n; + console.log( + `Deadline: ${deadline} (${new Date(Number(deadline) * 1000).toISOString()})`, + ); + + const { signedMulticall: approveAndTwap, gasLimit: approveAndTwapGasLimit } = + await cowShedSdk.signCalls({ + chainId: CHAIN_ID, + calls: [ + { + callData: approveSellTokenCalldata, + target: twapSellToken.address, + value: 0n, + isDelegateCall: false, + allowFailure: true, + }, + { + callData: twap.createCalldata, + target: COMPOSABLE_COW_CONTRACT_ADDRESS[CHAIN_ID], + value: 0n, + isDelegateCall: false, + allowFailure: true, + }, + ], + deadline, + signer: wallet, + defaultGasLimit: DEFAULT_GAS_LIMIT, + }); + console.log("Signed approve+twap calldata:", approveAndTwap); + + // Sell=buy order: a minimal sell == buy order whose sole purpose is to execute the + // post-hook that creates the TWAP. + // It does not move the full TWAP sell amount. + const { quoteResults, postSwapOrderFromQuote } = await sdk.getQuote( + { + kind: OrderKind.BUY, + sellToken: twapSellToken.address, + sellTokenDecimals: twapSellToken.decimals, + buyToken: twapSellToken.address, // sell == buy + buyTokenDecimals: twapSellToken.decimals, + amount: sellBuyOrderBuyAmount.toString(), // buy 1 wei of sDAI + + receiver: eoaTrader, // bought tokens stay with the trader; cow-shed needs no funds + owner: eoaTrader, + partiallyFillable: false, + validFor: 1800, + }, + { + appData: { + appCode: APP_CODE, + metadata: { + hooks: { + post: [ + // Approve the Vault Relayer and create the TWAP + { + callData: approveAndTwap.data, + gasLimit: approveAndTwapGasLimit.toString(), + target: approveAndTwap.to, + dappId: + "cow-sdk-scripts://composable-cow/post-twap-for-eoa-jit", + }, + ], + }, + }, + }, + }, + ); + + // Print the quote + printQuote(quoteResults); + + // Ask for confirmation before doing anything on-chain + const confirmed = await confirm( + `This will: + 1. Approve the Vault Relayer to spend ${twapSellToken.symbol} (for the sell=buy order). + 2. Approve the ComposableCowPoller to spend up to ${sellAmountFormatted} ${twapSellToken.symbol} (the full TWAP sell amount, pulled JIT). + 3. Register the JIT funding schedule on the poller (funder: your EOA, owner: cow-shed). + 4. Place the sell=buy order, whose post-hook creates the TWAP. + ... + 5. [watch-tower] Detects the TWAP and creates each part, which settle and proceeds are sent back to the EOA. The orders are owned by cow-shed, check explorer: https://explorer.cow.fi/gc/address/${cowShed} +The watch-tower will then pull ${twapSellToken.symbol} from your EOA part by part. ok?`, + ); + if (!confirmed) { + console.log("Aborted"); + return; + } + + // 1. Approve the Vault Relayer for the sell=buy order's sell token (the max the + // sell=buy order could spend to buy 1 wei, i.e. mostly the fee). + await ensureAllowance({ + token: twapSellToken, + owner: eoaTrader, + spender: COW_VAULT_RELAYER_CONTRACT, + requiredAmount: BigNumber.from( + quoteResults.amountsAndCosts.afterSlippage.sellAmount, + ), + label: "Vault Relayer", + }); + + // 2. Approve the poller to pull the full TWAP sell amount from the EOA over time + await ensureAllowance({ + token: twapSellToken, + owner: eoaTrader, + spender: COMPOSABLE_COW_POLLER_ADDRESS, + requiredAmount: sellAmount, + label: "ComposableCowPoller", + }); + + // 3. Register the JIT funding schedule (only the funder may register). + // NOTE: I could make the poller registration also accept a signature. This way, it can be also part of the sell-buy hook that creates the TWAP + const poller = getComposableCowPollerContract( + COMPOSABLE_COW_POLLER_ADDRESS, + wallet, + ); + const existing = await poller.schedules(ctx); + if (existing.funder !== ethers.constants.AddressZero) { + console.log( + `Schedule already registered for ctx ${ctx} (funder: ${existing.funder}). Skipping register.`, + ); + } else { + console.log("Registering JIT funding schedule on the poller..."); + const registerTx = await poller.register(ctx, { + handler, + funder: eoaTrader, + owner: cowShed, + staticInput, + }); + console.log("Register tx:", registerTx.hash); + await registerTx.wait(); + console.log("Schedule registered"); + } + + // 4. Place the sell=buy order. Its post-hook creates the TWAP gaslessly. + const { orderId } = await postSwapOrderFromQuote(); + console.log( + `Sell=buy order created, id: https://explorer.cow.fi/gc/orders/${orderId}?tab=overview`, + ); + console.log( + `Once it settles, the TWAP (ctx ${ctx}) will be live and funded just-in-time by the watch-tower.`, + ); +} + +async function ensureAllowance(params: { + token: Token; + owner: string; + spender: string; + requiredAmount: BigNumber; + label: string; +}) { + const { token, owner, spender, requiredAmount, label } = params; + const allowance: BigNumber = await token.contract.allowance(owner, spender); + console.log( + `Allowance for ${label}: ${ethers.utils.formatUnits( + allowance, + token.decimals, + )} ${token.symbol}`, + ); + if (allowance.gte(requiredAmount)) { + return; + } + + console.log(`Approving ${token.symbol} for ${label}...`); + const tx = await token.contract.approve(spender, ethers.constants.MaxUint256); + console.log(`Approving ${token.symbol} for ${label}. tx:`, tx.hash); + await tx.wait(); + console.log(`${token.symbol} approved for ${label}`); +} + +async function getAssetsInfo(params: { wallet: ethers.Wallet }): Promise<{ + twapSellToken: Token; + twapBuyToken: Token; +}> { + const { wallet } = params; + + const twapSellToken = await getErc20Contract(TOKENS.twapSellToken, wallet); + const twapBuyToken = await getErc20Contract(TOKENS.twapBuyToken, wallet); + + const [ + twapSellTokenSymbol, + twapSellTokenDecimals, + twapBuyTokenSymbol, + twapBuyTokenDecimals, + ] = await Promise.all([ + twapSellToken.symbol(), + twapSellToken.decimals(), + twapBuyToken.symbol(), + twapBuyToken.decimals(), + ]); + + return { + twapSellToken: { + symbol: twapSellTokenSymbol, + address: twapSellToken.address, + decimals: twapSellTokenDecimals, + contract: twapSellToken, + }, + twapBuyToken: { + symbol: twapBuyTokenSymbol, + address: twapBuyToken.address, + decimals: twapBuyTokenDecimals, + contract: twapBuyToken, + }, + }; +} From 5f3564a4ee3627fa10584f392bbcfa9dbfa2cd56 Mon Sep 17 00:00:00 2001 From: Anxo <2352112+anxolin@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:49:07 +0100 Subject: [PATCH 2/4] Enhance script --- src/scripts/composable-cow/cowShed.ts | 20 +++ src/scripts/composable-cow/postTwapForEOA.ts | 41 ++--- .../postTwapForEOAWithJitFunds.ts | 162 ++++++++++++------ 3 files changed, 150 insertions(+), 73 deletions(-) create mode 100644 src/scripts/composable-cow/cowShed.ts diff --git a/src/scripts/composable-cow/cowShed.ts b/src/scripts/composable-cow/cowShed.ts new file mode 100644 index 0000000..091ab57 --- /dev/null +++ b/src/scripts/composable-cow/cowShed.ts @@ -0,0 +1,20 @@ +import { CowShedSdk } from "@cowprotocol/cow-sdk"; + +// Factory options for the COWShedForComposableCoW deployment on Gnosis Chain. +// See https://gnosisscan.io/address/0x4f4350bf2c74aacd508d598a1ba94ef84378793d#readContract +// and https://github.com/cowdao-grants/cow-shed/pull/53 +const COW_SHED_FACTORY_OPTIONS = { + factoryAddress: "0x4f4350bf2c74aacd508d598a1ba94ef84378793d", + implementationAddress: "0x6773d5aA31A1EAD34127D564D6E258E66254EbDb", // COWShedForComposableCoW implementation + proxyCreationCode: + // See https://gnosisscan.io/address/0x4f4350bf2c74aacd508d598a1ba94ef84378793d#readContract + "0x60a03461009557601f61033d38819003918201601f19168301916001600160401b0383118484101761009957808492604094855283398101031261009557610052602061004b836100ad565b92016100ad565b6080527f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5560405161027b90816100c28239608051818181608b01526101750152f35b5f80fd5b634e487b7160e01b5f52604160045260245ffd5b51906001600160a01b03821682036100955756fe60806040526004361015610018575b3661019757610197565b5f3560e01c8063025b22bc146100375763f851a4400361000e57610116565b346101125760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101125760043573ffffffffffffffffffffffffffffffffffffffff81169081810361011257337f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff160361010d577f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc557fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b5f80a2005b61023d565b5f80fd5b34610112575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261011257602061014e61016c565b73ffffffffffffffffffffffffffffffffffffffff60405191168152f35b33300361010d577f000000000000000000000000000000000000000000000000000000000000000090565b60ff7f68df44b1011761f481358c0f49a711192727fb02c377d697bcb0ea8ff8393ac0541615806101f0575b1561023d577ff92ee8a9000000000000000000000000000000000000000000000000000000005f5260045ffd5b507fc4d66de8000000000000000000000000000000000000000000000000000000007fffffffff000000000000000000000000000000000000000000000000000000005f351614156101c3565b5f807f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc54368280378136915af43d5f803e15610277573d5ff35b3d5ffd", +} as const; + +/** + * Build a `CowShedSdk` configured for the COWShedForComposableCoW deployment, + * which is the cow-shed flavour with support for Composable CoW. + */ +export function getCowShedSdk(): CowShedSdk { + return new CowShedSdk({ factoryOptions: { ...COW_SHED_FACTORY_OPTIONS } }); +} diff --git a/src/scripts/composable-cow/postTwapForEOA.ts b/src/scripts/composable-cow/postTwapForEOA.ts index 527c220..6851947 100644 --- a/src/scripts/composable-cow/postTwapForEOA.ts +++ b/src/scripts/composable-cow/postTwapForEOA.ts @@ -5,7 +5,6 @@ import { OrderKind, TradingSdk, Twap, - CowShedSdk, COMPOSABLE_COW_CONTRACT_ADDRESS, OrderBookApi, } from "@cowprotocol/cow-sdk"; @@ -14,6 +13,7 @@ import { MetadataApi } from "@cowprotocol/app-data"; import { BigNumber, ethers } from "ethers"; import { confirm, debugStringify, getWallet, printQuote } from "../../utils"; import { getErc20Contract } from "../../contracts/erc20"; +import { getCowShedSdk } from "./cowShed"; // import { latest } from "@cowprotocol/app-data"; const DEFAULT_GAS_LIMIT = 500_000n; @@ -52,20 +52,13 @@ export async function run() { const { beforeTwapSellToken, twapSellToken, twapBuyToken } = await getAssetsInfo({ wallet, trader: eoaTrader }); - const sellAmount = ethers.utils.parseUnits("0.2", twapSellToken.decimals); // 0.1 EURe + const sellAmount = ethers.utils.parseUnits("0.2", twapSellToken.decimals); // 0.1 sDAI const sellAmountFormatted = ethers.utils.formatUnits( sellAmount, - twapSellToken.decimals + twapSellToken.decimals, ); - const cowShedSdk = new CowShedSdk({ - factoryOptions: { - factoryAddress: "0x4f4350bf2c74aacd508d598a1ba94ef84378793d", - implementationAddress: "0x6773d5aA31A1EAD34127D564D6E258E66254EbDb", - proxyCreationCode: - "0x60a03461009557601f61033d38819003918201601f19168301916001600160401b0383118484101761009957808492604094855283398101031261009557610052602061004b836100ad565b92016100ad565b6080527f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5560405161027b90816100c28239608051818181608b01526101750152f35b5f80fd5b634e487b7160e01b5f52604160045260245ffd5b51906001600160a01b03821682036100955756fe60806040526004361015610018575b3661019757610197565b5f3560e01c8063025b22bc146100375763f851a4400361000e57610116565b346101125760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101125760043573ffffffffffffffffffffffffffffffffffffffff81169081810361011257337f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff160361010d577f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc557fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b5f80a2005b61023d565b5f80fd5b34610112575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261011257602061014e61016c565b73ffffffffffffffffffffffffffffffffffffffff60405191168152f35b33300361010d577f000000000000000000000000000000000000000000000000000000000000000090565b60ff7f68df44b1011761f481358c0f49a711192727fb02c377d697bcb0ea8ff8393ac0541615806101f0575b1561023d577ff92ee8a9000000000000000000000000000000000000000000000000000000005f5260045ffd5b507fc4d66de8000000000000000000000000000000000000000000000000000000007fffffffff000000000000000000000000000000000000000000000000000000005f351614156101c3565b5f807f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc54368280378136915af43d5f803e15610277573d5ff35b3d5ffd", - }, - }); + const cowShedSdk = getCowShedSdk(); const cowShed = cowShedSdk.getCowShedAccount(CHAIN_ID, eoaTrader); console.log("CowShed account:", cowShed); @@ -74,7 +67,7 @@ export async function run() { `TWAP sell ${sellAmountFormatted} ${twapSellToken.symbol} for ${twapBuyToken.symbol} in ${PARTS} parts. To create the TWAP we we will use for this PoC an intermediate order with a post hook: - Buy ${sellAmountFormatted} ${twapSellToken.symbol} with ${beforeTwapSellToken.symbol}, sent to ${cowShed} - - Post-hook will create the TWAP using cow-shed. Each part sells ${twapSellToken.symbol} for ${twapBuyToken.symbol}` + - Post-hook will create the TWAP using cow-shed. Each part sells ${twapSellToken.symbol} for ${twapBuyToken.symbol}`, ); // Generate app data for TWAP order @@ -125,16 +118,16 @@ To create the TWAP we we will use for this PoC an intermediate order with a post const approveSellTokenGasLimit = await twapSellToken.contract.estimateGas.approve( COW_VAULT_RELAYER_CONTRACT, - sellAmount + sellAmount, ); console.log( "Approve sell token gas limit:", - approveSellTokenGasLimit.toString() + approveSellTokenGasLimit.toString(), ); const deadline = BigInt(Math.ceil(Date.now() / 1000)) + 1800n; console.log( - `Deadline: ${deadline} (${new Date(Number(deadline) * 1000).toISOString()})` + `Deadline: ${deadline} (${new Date(Number(deadline) * 1000).toISOString()})`, ); const { signedMulticall: approveAndTwap, gasLimit: approveAndTwapGasLimit } = @@ -195,7 +188,7 @@ To create the TWAP we we will use for this PoC an intermediate order with a post }, }, }, - } + }, ); // Print the quote @@ -204,29 +197,29 @@ To create the TWAP we we will use for this PoC an intermediate order with a post quoteResults.amountsAndCosts.afterSlippage.sellAmount; const sellAmountIntialTradeFormatted = ethers.utils.formatUnits( sellAmountIntialTrade, - beforeTwapSellToken.decimals + beforeTwapSellToken.decimals, ); // Ask for confirmation before posting the order const confirmed = await confirm( - `Your CoW Shed will get exactly ${sellAmountFormatted} ${twapSellToken.symbol} for at most ${sellAmountIntialTradeFormatted} ${beforeTwapSellToken.symbol}. Then a TWAP will be created with each part selling ${twapSellToken.symbol} for ${twapBuyToken.symbol}. ok?` + `Your CoW Shed will get exactly ${sellAmountFormatted} ${twapSellToken.symbol} for at most ${sellAmountIntialTradeFormatted} ${beforeTwapSellToken.symbol}. Then a TWAP will be created with each part selling ${twapSellToken.symbol} for ${twapBuyToken.symbol}. ok?`, ); if (confirmed) { const allowance = await beforeTwapSellToken.contract.allowance( eoaTrader, - COW_VAULT_RELAYER_CONTRACT + COW_VAULT_RELAYER_CONTRACT, ); console.log( - `Allowance for Vault Relayer: ${allowance} ${beforeTwapSellToken.symbol}` + `Allowance for Vault Relayer: ${allowance} ${beforeTwapSellToken.symbol}`, ); if (allowance < sellAmountIntialTrade) { console.log( - `Approving sell token for: ${sellAmountIntialTradeFormatted} ${beforeTwapSellToken.symbol}` + `Approving sell token for: ${sellAmountIntialTradeFormatted} ${beforeTwapSellToken.symbol}`, ); const tx = await beforeTwapSellToken.contract.approve( COW_VAULT_RELAYER_CONTRACT, - ethers.constants.MaxUint256 + ethers.constants.MaxUint256, // sellAmountIntialTrade ); console.log(`Approving ${beforeTwapSellToken.symbol}. tx:`, tx.hash); @@ -238,7 +231,7 @@ To create the TWAP we we will use for this PoC an intermediate order with a post const { orderId } = await postSwapOrderFromQuote(); console.log( - `Order created, id: https://explorer.cow.fi/gc/orders/${orderId}?tab=overview` + `Order created, id: https://explorer.cow.fi/gc/orders/${orderId}?tab=overview`, ); } } @@ -256,7 +249,7 @@ async function getAssetsInfo(params: { // Get ERC20 balance for oldUnderlying using ethersjs const beforeTwapSellToken = await getErc20Contract( TOKENS.beforeTwapSellToken, - wallet + wallet, ); const twapSellToken = await getErc20Contract(TOKENS.twapSellToken, wallet); const twapBuyToken = await getErc20Contract(TOKENS.twapBuyToken, wallet); diff --git a/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts b/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts index f27b03e..b0a0542 100644 --- a/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts +++ b/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts @@ -6,16 +6,22 @@ import { OrderKind, TradingSdk, Twap, - CowShedSdk, COMPOSABLE_COW_CONTRACT_ADDRESS, OrderBookApi, } from "@cowprotocol/cow-sdk"; import { MetadataApi } from "@cowprotocol/app-data"; import { BigNumber, ethers } from "ethers"; -import { confirm, debugStringify, getWallet, printQuote } from "../../utils"; +import { + confirm, + debugStringify, + getExplorerUrl, + getWallet, + printQuote, +} from "../../utils"; import { getErc20Contract } from "../../contracts/erc20"; import { getComposableCowPollerContract } from "../../contracts/composable-cow-poller"; +import { getCowShedSdk } from "./cowShed"; const DEFAULT_GAS_LIMIT = 500_000n; @@ -31,17 +37,9 @@ const TOKENS = { twapBuyToken: "0x177127622c4A00F3d409B75571e12cB3c8973d3c", // COW } as const; -const PARTS = 2; -const TIME_BETWEEN_PARTS = 300; // seconds - -// The sell=buy order's only purpose is to get the post-hook (which creates the -// TWAP) executed gaslessly via a settlement. -// -// It does NOT move the full TWAP sell amount: This is why no the recipient of the funds is still the EOA. -// as opposed to what src/scripts/composable-cow/postTwapForEOA.ts does -// -// Thanks to JIT funding, each part is pulled from the EOA right before it settles. -const SELL_BUY_ORDER_BUY_AMOUNT = "1"; // 1 wei of sDAI to buy (arrives to the EOA) +const TWAP_PARTS = 2; +const TWAP_TIME_BETWEEN_PARTS = 60; // 60s +const TWAP_SLIPPAGE_BPS = 1000; // 10% const CHAIN_ID = SupportedChainId.GNOSIS_CHAIN; @@ -59,32 +57,52 @@ export async function run() { // Get some info about the assets const { twapSellToken, twapBuyToken } = await getAssetsInfo({ wallet }); - const sellAmount = ethers.utils.parseUnits("0.2", twapSellToken.decimals); // total TWAP sell amount - const sellAmountFormatted = ethers.utils.formatUnits( - sellAmount, + // First order (sell=buy order): + // The sell=buy order'sonly purpose is to get the post-hook (which creates the + // TWAP) executed gaslessly via a settlement. + // + // It does NOT move the full TWAP sell amount: This is why no the recipient of the funds is still the EOA. + // as opposed to what src/scripts/composable-cow/postTwapForEOA.ts does + // + // Thanks to JIT funding, each part is pulled from the EOA right before it settles. + const firstOrderBuyAmount = BigNumber.from("1"); // sell=buy order: Buy 1 wei of sDAI + + // TWAP Order: + const fullSellAmount = ethers.utils.parseUnits("0.2", twapSellToken.decimals); // TWAP order: Sell a total of 0.2 sDAI + const fullSellAmountFormatted = ethers.utils.formatUnits( + fullSellAmount, twapSellToken.decimals, ); - const sellBuyOrderBuyAmount = BigNumber.from(SELL_BUY_ORDER_BUY_AMOUNT); // 1 wei of sDAI - - const cowShedSdk = new CowShedSdk({ - factoryOptions: { - factoryAddress: "0x4f4350bf2c74aacd508d598a1ba94ef84378793d", - implementationAddress: "0x6773d5aA31A1EAD34127D564D6E258E66254EbDb", - proxyCreationCode: - "0x60a03461009557601f61033d38819003918201601f19168301916001600160401b0383118484101761009957808492604094855283398101031261009557610052602061004b836100ad565b92016100ad565b6080527f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5560405161027b90816100c28239608051818181608b01526101750152f35b5f80fd5b634e487b7160e01b5f52604160045260245ffd5b51906001600160a01b03821682036100955756fe60806040526004361015610018575b3661019757610197565b5f3560e01c8063025b22bc146100375763f851a4400361000e57610116565b346101125760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101125760043573ffffffffffffffffffffffffffffffffffffffff81169081810361011257337f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff160361010d577f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc557fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b5f80a2005b61023d565b5f80fd5b34610112575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261011257602061014e61016c565b73ffffffffffffffffffffffffffffffffffffffff60405191168152f35b33300361010d577f000000000000000000000000000000000000000000000000000000000000000090565b60ff7f68df44b1011761f481358c0f49a711192727fb02c377d697bcb0ea8ff8393ac0541615806101f0575b1561023d577ff92ee8a9000000000000000000000000000000000000000000000000000000005f5260045ffd5b507fc4d66de8000000000000000000000000000000000000000000000000000000007fffffffff000000000000000000000000000000000000000000000000000000005f351614156101c3565b5f807f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc54368280378136915af43d5f803e15610277573d5ff35b3d5ffd", - }, - }); + + const partSellAmount = fullSellAmount.div(TWAP_PARTS); + const partSellAmountFormatted = ethers.utils.formatUnits( + partSellAmount, + twapSellToken.decimals, + ); + + const cowShedSdk = getCowShedSdk(); const cowShed = cowShedSdk.getCowShedAccount(CHAIN_ID, eoaTrader); console.log("CowShed account:", cowShed); // Describe the flow console.log( - `TWAP sell ${sellAmountFormatted} ${twapSellToken.symbol} for ${twapBuyToken.symbol} in ${PARTS} parts, funded just-in-time. + `TWAP sell ${fullSellAmountFormatted} ${twapSellToken.symbol} for ${twapBuyToken.symbol} in ${TWAP_PARTS} parts (funded just-in-time). + The setup is done with a gasless sell=buy order with a post-hook: - - Sell=buy order: BUY ${SELL_BUY_ORDER_BUY_AMOUNT} wei of ${twapSellToken.symbol} with ${twapSellToken.symbol} (sell == buy), so the only thing it does is carry the post-hook. - - Post-hook (via cow-shed): approve the Vault Relayer + create the TWAP. Owner of the TWAP is cow-shed (${cowShed}). -The EOA keeps the ${twapSellToken.symbol} and approves the ComposableCowPoller (${COMPOSABLE_COW_POLLER_ADDRESS}). -Before each part settles, the watch-tower calls poller.topUp(ctx), pulling exactly that part's sell amount from the EOA into cow-shed.`, + - Sell=buy order: BUY 1 wei of ${twapSellToken.symbol} with ${twapSellToken.symbol} (sell == buy) + - Order executes a post-hook (via cow-shed): + - Approve the Vault Relayer + - Create the TWAP. Owner of the TWAP is cow-shed (${cowShed}). + - Technically, we can include more things here which are left out of this PoC but are a good idea for the production flow: + - Approve the ComposableCowPoller + - Register the JIT funding schedule on ComposableCowPoller + +The EOA keeps the 1 wei of ${twapSellToken.symbol}, which means that the EOA is the recipient of the first order. +The order will have the side-effects described above. + +Watch Tower will detect the TWAP and create each part, which will settle and send the proceeds back to the EOA. +Before each part settles, the hook calls poller.topUp(ctx), pulling exactly that part's sell amount from the EOA into cow-shed. +`, ); // Generate app data for TWAP order @@ -101,6 +119,37 @@ Before each part settles, the watch-tower calls poller.topUp(ctx), pulling exact chainId: CHAIN_ID, }); + // Quote a single part (sell token -> buy token) to derive a sensible buy amount + // limit. We pass our slippage tolerance so `afterSlippage.buyAmount` is the + // minimum we are willing to receive per part. The TWAP's total buy amount is + // then that per-part minimum scaled by the number of parts. + const { quoteResults: partQuote } = await sdk.getQuote({ + kind: OrderKind.SELL, + sellToken: twapSellToken.address, + sellTokenDecimals: twapSellToken.decimals, + buyToken: twapBuyToken.address, + buyTokenDecimals: twapBuyToken.decimals, + amount: partSellAmount.toString(), + owner: eoaTrader, + slippageBps: TWAP_SLIPPAGE_BPS, + }); + // Expected amount (net of costs, before slippage) and the minimum we will sign + // (after slippage), both per part and scaled to the whole TWAP. + const expectedPartBuyAmount = BigNumber.from( + partQuote.amountsAndCosts.afterNetworkCosts.buyAmount, + ); + const partBuyAmount = BigNumber.from( + partQuote.amountsAndCosts.afterSlippage.buyAmount, + ); + const expectedTwapBuyAmount = expectedPartBuyAmount.mul(TWAP_PARTS); + const twapBuyAmount = partBuyAmount.mul(TWAP_PARTS); + const fmt = (amount: BigNumber) => + `${ethers.utils.formatUnits(amount, twapBuyToken.decimals)} ${twapBuyToken.symbol}`; + console.log( + `TWAP buy amount per part: ~${fmt(expectedPartBuyAmount)} expected, ${fmt(partBuyAmount)} min (after ${TWAP_SLIPPAGE_BPS / 100}% slippage). +TWAP buy amount total: ~${fmt(expectedTwapBuyAmount)} expected, ${fmt(twapBuyAmount)} min.`, + ); + // Build the TWAP. Owner is cow-shed (the order owner / pull destination); the // bought tokens are sent to the trader. `startTime` defaults to AT_MINING_TIME // (t0 = 0), so `createCalldata` uses `createWithContext` with the @@ -108,10 +157,10 @@ Before each part settles, the watch-tower calls poller.topUp(ctx), pulling exact // what lets the poller's `getTradeableOrder` resolve the live part. const twap = Twap.fromData({ receiver: eoaTrader, - sellAmount: sellAmount, - buyAmount: BigNumber.from(PARTS), // TODO: Get another quote and apply a good slippage - numberOfParts: BigNumber.from(PARTS), - timeBetweenParts: BigNumber.from(TIME_BETWEEN_PARTS), + sellAmount: fullSellAmount, + buyAmount: twapBuyAmount, + numberOfParts: BigNumber.from(TWAP_PARTS), + timeBetweenParts: BigNumber.from(TWAP_TIME_BETWEEN_PARTS), sellToken: twapSellToken.address, buyToken: twapBuyToken.address, appData: twapAppDataHex, @@ -171,9 +220,13 @@ Before each part settles, the watch-tower calls poller.topUp(ctx), pulling exact }); console.log("Signed approve+twap calldata:", approveAndTwap); - // Sell=buy order: a minimal sell == buy order whose sole purpose is to execute the - // post-hook that creates the TWAP. - // It does not move the full TWAP sell amount. + // Sell=buy order (so, it's a no-operation order) + // Both sellToken and buyOrder matches the TWAP's sellToken + // The post-hook that creates the TWAP. + // It does not move the full TWAP sell amount: + // - This is why the trade amount tries to be minimal (BUY 1 wei, for whatever the quote endpoint said I need to pay for gas) + // - Because funds don't arrive to cow-shed, the recipient can be the EOA (this is where the 1 wei is bought) + // - The funds will be pulled in follow up order's hook (orders will automatically be created by watch-tower) const { quoteResults, postSwapOrderFromQuote } = await sdk.getQuote( { kind: OrderKind.BUY, @@ -181,9 +234,8 @@ Before each part settles, the watch-tower calls poller.topUp(ctx), pulling exact sellTokenDecimals: twapSellToken.decimals, buyToken: twapSellToken.address, // sell == buy buyTokenDecimals: twapSellToken.decimals, - amount: sellBuyOrderBuyAmount.toString(), // buy 1 wei of sDAI - - receiver: eoaTrader, // bought tokens stay with the trader; cow-shed needs no funds + amount: firstOrderBuyAmount.toString(), // buy 1 wei of sDAI + receiver: eoaTrader, // bought tokens stay with the trader; cow-shed needs no funds until each part is settled owner: eoaTrader, partiallyFillable: false, validFor: 1800, @@ -216,12 +268,17 @@ Before each part settles, the watch-tower calls poller.topUp(ctx), pulling exact const confirmed = await confirm( `This will: 1. Approve the Vault Relayer to spend ${twapSellToken.symbol} (for the sell=buy order). - 2. Approve the ComposableCowPoller to spend up to ${sellAmountFormatted} ${twapSellToken.symbol} (the full TWAP sell amount, pulled JIT). + 2. Approve the ComposableCowPoller to spend up to ${fullSellAmountFormatted} ${twapSellToken.symbol} (the full TWAP sell amount, pulled JIT). 3. Register the JIT funding schedule on the poller (funder: your EOA, owner: cow-shed). 4. Place the sell=buy order, whose post-hook creates the TWAP. ... - 5. [watch-tower] Detects the TWAP and creates each part, which settle and proceeds are sent back to the EOA. The orders are owned by cow-shed, check explorer: https://explorer.cow.fi/gc/address/${cowShed} -The watch-tower will then pull ${twapSellToken.symbol} from your EOA part by part. ok?`, + 5. [watch-tower] Detects the TWAP and creates each part, which settle and proceeds are sent back to the EOA. + +🥳 Each part will poll ${partSellAmountFormatted} ${twapSellToken.symbol} from your EOA before filling. + +Your EOA will receive: ~${fmt(expectedTwapBuyAmount)} (expected), at least ${fmt(twapBuyAmount)} (min, after ${TWAP_SLIPPAGE_BPS / 100}% slippage) across the ${TWAP_PARTS} parts. + +ok?`, ); if (!confirmed) { console.log("Aborted"); @@ -241,16 +298,18 @@ The watch-tower will then pull ${twapSellToken.symbol} from your EOA part by par }); // 2. Approve the poller to pull the full TWAP sell amount from the EOA over time + // NOTE: For permit tokens, we can include the permit call as part of the first order await ensureAllowance({ token: twapSellToken, owner: eoaTrader, spender: COMPOSABLE_COW_POLLER_ADDRESS, - requiredAmount: sellAmount, + requiredAmount: fullSellAmount, label: "ComposableCowPoller", }); // 3. Register the JIT funding schedule (only the funder may register). - // NOTE: I could make the poller registration also accept a signature. This way, it can be also part of the sell-buy hook that creates the TWAP + // NOTE: We could make the poller registration also accept a signature. + // This way, this part can always be chained as part of the first-order post-hook and we don't need this transaction const poller = getComposableCowPollerContract( COMPOSABLE_COW_POLLER_ADDRESS, wallet, @@ -268,7 +327,7 @@ The watch-tower will then pull ${twapSellToken.symbol} from your EOA part by par owner: cowShed, staticInput, }); - console.log("Register tx:", registerTx.hash); + console.log("Register tx:", getExplorerUrl(CHAIN_ID, registerTx.hash)); await registerTx.wait(); console.log("Schedule registered"); } @@ -279,7 +338,9 @@ The watch-tower will then pull ${twapSellToken.symbol} from your EOA part by par `Sell=buy order created, id: https://explorer.cow.fi/gc/orders/${orderId}?tab=overview`, ); console.log( - `Once it settles, the TWAP (ctx ${ctx}) will be live and funded just-in-time by the watch-tower.`, + `Once it settles, the TWAP (ctx ${ctx}) will be live and funded (just-in-time). + +Monitor parts in https://explorer.cow.fi/gc/address/${cowShed}`, ); } @@ -304,7 +365,10 @@ async function ensureAllowance(params: { console.log(`Approving ${token.symbol} for ${label}...`); const tx = await token.contract.approve(spender, ethers.constants.MaxUint256); - console.log(`Approving ${token.symbol} for ${label}. tx:`, tx.hash); + console.log( + `Approving ${token.symbol} for ${label}. tx:`, + getExplorerUrl(CHAIN_ID, tx.hash), + ); await tx.wait(); console.log(`${token.symbol} approved for ${label}`); } From c5b22b64fe4a6534e4fbbe91ad1aaadb43fee564 Mon Sep 17 00:00:00 2001 From: Anxo <2352112+anxolin@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:56:01 +0100 Subject: [PATCH 3/4] Add slippage and hook --- src/const/gnosis.ts | 4 +- src/contracts/composable-cow-poller/index.ts | 13 +- .../postTwapForEOAWithJitFunds.ts | 119 +++++++++++++----- 3 files changed, 97 insertions(+), 39 deletions(-) diff --git a/src/const/gnosis.ts b/src/const/gnosis.ts index bdf46a9..f406fb1 100644 --- a/src/const/gnosis.ts +++ b/src/const/gnosis.ts @@ -1,6 +1,8 @@ export const GNO_ADDRESS = "0x9C58BAcC331c9aa871AFD802DB6379a98e80CEdb"; // ComposableCowPoller: just-in-time funding for composable conditional orders. +// `id`-keyed deployment (schedule key is independent of the order's appData, so +// `topUp(id)` can be embedded as a pre-hook in the order's own appData). // See https://github.com/cowprotocol/composable-cow/pull/116 export const COMPOSABLE_COW_POLLER_ADDRESS = - "0x3ba140fae6a1688aaddc8ad37ea0f6230d6b6178"; + "0xA360eE11eD0d2025604518CF4B8F6e6CB76C7Df7"; diff --git a/src/contracts/composable-cow-poller/index.ts b/src/contracts/composable-cow-poller/index.ts index 20f17be..214205d 100644 --- a/src/contracts/composable-cow-poller/index.ts +++ b/src/contracts/composable-cow-poller/index.ts @@ -12,11 +12,12 @@ import { ethers } from "ethers"; */ const COMPOSABLE_COW_POLLER_ABI = [ "function composableCow() external view returns (address)", - "function register(bytes32 ctx, (address handler, address funder, address owner, bytes staticInput) schedule) external", - "function revoke(bytes32 ctx) external", - "function topUp(bytes32 ctx) external", - "function schedules(bytes32 ctx) external view returns (address handler, address funder, address owner, bytes staticInput)", - "function lastFunded(bytes32 ctx) external view returns (bytes32)", + "function scheduleId(address funder, address handler, address owner, bytes32 salt) external pure returns (bytes32)", + "function register((address handler, address funder, address owner, bytes32 salt, bytes staticInput) schedule) external returns (bytes32 id)", + "function revoke(bytes32 id) external", + "function topUp(bytes32 id) external", + "function schedules(bytes32 id) external view returns (address handler, address funder, address owner, bytes32 salt, bytes staticInput)", + "function lastFunded(bytes32 id) external view returns (bytes32)", ] as const; export interface PollerSchedule { @@ -26,6 +27,8 @@ export interface PollerSchedule { funder: string; /** Order owner (cow-shed / Safe); the fixed pull destination. */ owner: string; + /** The conditional order's `salt`; lets the poller rebuild `ctx` on-chain. */ + salt: string; /** The order's `staticInput`, passed verbatim to `getTradeableOrder`. */ staticInput: string; } diff --git a/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts b/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts index b0a0542..487589d 100644 --- a/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts +++ b/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts @@ -38,8 +38,14 @@ const TOKENS = { } as const; const TWAP_PARTS = 2; -const TWAP_TIME_BETWEEN_PARTS = 60; // 60s -const TWAP_SLIPPAGE_BPS = 1000; // 10% +const TWAP_TIME_BETWEEN_PARTS = 120; // 2min +const TWAP_SLIPPAGE_BPS = 1000; // 1000 bps (10%) +const FIRST_ORDER_SLIPPAGE_BPS = 20000000000; // 200,000,000% // TODO: This was a test because I could see backend not executing the order if the sellAmount was subcent. But this should be something like 0.5% + +// The TWAP handler (ComposableCoW order type). Deterministic across chains. +const TWAP_HANDLER = "0x6cF1e9cA41f7611dEf408122793c358a3d11E5a5"; +// Gas budget for the topUp pre-hook on each part (SLOADs + getTradeableOrder + transferFrom). +const TOPUP_HOOK_GAS_LIMIT = "350000"; const CHAIN_ID = SupportedChainId.GNOSIS_CHAIN; @@ -84,6 +90,24 @@ export async function run() { const cowShed = cowShedSdk.getCowShedAccount(CHAIN_ID, eoaTrader); console.log("CowShed account:", cowShed); + // The poller schedule key. It is derived from appData-INDEPENDENT fields + // (funder, handler, owner, salt), which is exactly what lets us embed + // `topUp(id)` as a pre-hook inside the TWAP's own appData: the order's `ctx` + // contains the appData hash, so keying on `ctx` would be circular, but `id` + // is not. We choose the salt, so we can compute `id` before the appData. + const poller = getComposableCowPollerContract( + COMPOSABLE_COW_POLLER_ADDRESS, + wallet, + ); + const twapSalt = ethers.utils.hexlify(ethers.utils.randomBytes(32)); + const id: string = await poller.scheduleId( + eoaTrader, + TWAP_HANDLER, + cowShed, + twapSalt, + ); + console.log("Poller schedule id:", id); + // Describe the flow console.log( `TWAP sell ${fullSellAmountFormatted} ${twapSellToken.symbol} for ${twapBuyToken.symbol} in ${TWAP_PARTS} parts (funded just-in-time). @@ -101,16 +125,29 @@ The EOA keeps the 1 wei of ${twapSellToken.symbol}, which means that the EOA is The order will have the side-effects described above. Watch Tower will detect the TWAP and create each part, which will settle and send the proceeds back to the EOA. -Before each part settles, the hook calls poller.topUp(ctx), pulling exactly that part's sell amount from the EOA into cow-shed. +Each part carries a pre-hook (baked into the TWAP appData) that calls poller.topUp(id), pulling exactly that part's +sell amount from the EOA into cow-shed right before it settles. No external keeper is needed. `, ); - // Generate app data for TWAP order + // Generate app data for the TWAP, embedding a pre-hook with the polling const metadataApi = new MetadataApi(); + const topUpCalldata = poller.interface.encodeFunctionData("topUp", [id]); const twapAppData = await metadataApi.generateAppDataDoc({ appCode: APP_CODE, environment: "prod", - metadata: {}, + metadata: { + hooks: { + pre: [ + // Call: poll.topUp(id) + { + target: COMPOSABLE_COW_POLLER_ADDRESS, + callData: topUpCalldata, + gasLimit: TOPUP_HOOK_GAS_LIMIT, + }, + ], + }, + }, }); const { appDataContent: twapAppDataContent, appDataHex: twapAppDataHex } = await metadataApi.getAppDataInfo(twapAppData); @@ -150,26 +187,36 @@ Before each part settles, the hook calls poller.topUp(ctx), pulling exactly that TWAP buy amount total: ~${fmt(expectedTwapBuyAmount)} expected, ${fmt(twapBuyAmount)} min.`, ); - // Build the TWAP. Owner is cow-shed (the order owner / pull destination); the - // bought tokens are sent to the trader. `startTime` defaults to AT_MINING_TIME - // (t0 = 0), so `createCalldata` uses `createWithContext` with the - // CurrentBlockTimestampFactory, anchoring t0 to the settlement block. This is - // what lets the poller's `getTradeableOrder` resolve the live part. - const twap = Twap.fromData({ - receiver: eoaTrader, - sellAmount: fullSellAmount, - buyAmount: twapBuyAmount, - numberOfParts: BigNumber.from(TWAP_PARTS), - timeBetweenParts: BigNumber.from(TWAP_TIME_BETWEEN_PARTS), - sellToken: twapSellToken.address, - buyToken: twapBuyToken.address, - appData: twapAppDataHex, - }); + // Build the TWAP + const twap = Twap.fromData( + { + receiver: eoaTrader, // bought tokens are sent to the trader (the EOA) + sellAmount: fullSellAmount, + buyAmount: twapBuyAmount, + numberOfParts: BigNumber.from(TWAP_PARTS), + timeBetweenParts: BigNumber.from(TWAP_TIME_BETWEEN_PARTS), + sellToken: twapSellToken.address, + buyToken: twapBuyToken.address, + appData: twapAppDataHex, // appData, including the pre-hook to poll funds + }, + twapSalt, // controlled salt, so the schedule `id` matches the embedded hook + ); - // `ctx == ComposableCoW.hash(params) == twap.id` is the key both the cabinet - // and the poller schedule are stored under. + // `ctx == ComposableCoW.hash(params) == twap.id` is the order's cabinet key. + // The poller schedule is keyed by the appData-independent `id` instead. const ctx = twap.id; - const { handler, staticInput } = twap.leaf; + const { handler, salt, staticInput } = twap.leaf; + + // Sanity: the handler/salt must be exactly what we derived `id` from, so the + // `topUp(id)` hook baked into the appData resolves to this very schedule. + if ( + handler.toLowerCase() !== TWAP_HANDLER.toLowerCase() || + salt.toLowerCase() !== twapSalt.toLowerCase() + ) { + throw new Error( + `TWAP handler/salt mismatch: handler=${handler} salt=${salt} (expected handler=${TWAP_HANDLER} salt=${twapSalt})`, + ); + } console.log("TWAP context (ctx):", ctx); console.log("TWAP params for creation of order", { @@ -239,6 +286,7 @@ TWAP buy amount total: ~${fmt(expectedTwapBuyAmount)} expected, ${fmt(twapBuyAmo owner: eoaTrader, partiallyFillable: false, validFor: 1800, + slippageBps: FIRST_ORDER_SLIPPAGE_BPS, }, { appData: { @@ -264,6 +312,15 @@ TWAP buy amount total: ~${fmt(expectedTwapBuyAmount)} expected, ${fmt(twapBuyAmo // Print the quote printQuote(quoteResults); + // Calculate the maximum fee we will pay for the first order + const firstOrderMaxFee = BigNumber.from( + quoteResults.amountsAndCosts.afterSlippage.sellAmount - 1n, // deduct the 1 wei we get back + ); + const firstOrderMaxFeeFormatted = ethers.utils.formatUnits( + firstOrderMaxFee, + twapSellToken.decimals, + ); + // Ask for confirmation before doing anything on-chain const confirmed = await confirm( `This will: @@ -277,6 +334,7 @@ TWAP buy amount total: ~${fmt(expectedTwapBuyAmount)} expected, ${fmt(twapBuyAmo 🥳 Each part will poll ${partSellAmountFormatted} ${twapSellToken.symbol} from your EOA before filling. Your EOA will receive: ~${fmt(expectedTwapBuyAmount)} (expected), at least ${fmt(twapBuyAmount)} (min, after ${TWAP_SLIPPAGE_BPS / 100}% slippage) across the ${TWAP_PARTS} parts. +You will pay at most ${firstOrderMaxFeeFormatted} ${twapSellToken.symbol} for placing and setting up the TWAP. ok?`, ); @@ -291,9 +349,7 @@ ok?`, token: twapSellToken, owner: eoaTrader, spender: COW_VAULT_RELAYER_CONTRACT, - requiredAmount: BigNumber.from( - quoteResults.amountsAndCosts.afterSlippage.sellAmount, - ), + requiredAmount: firstOrderMaxFee, label: "Vault Relayer", }); @@ -310,21 +366,18 @@ ok?`, // 3. Register the JIT funding schedule (only the funder may register). // NOTE: We could make the poller registration also accept a signature. // This way, this part can always be chained as part of the first-order post-hook and we don't need this transaction - const poller = getComposableCowPollerContract( - COMPOSABLE_COW_POLLER_ADDRESS, - wallet, - ); - const existing = await poller.schedules(ctx); + const existing = await poller.schedules(id); if (existing.funder !== ethers.constants.AddressZero) { console.log( - `Schedule already registered for ctx ${ctx} (funder: ${existing.funder}). Skipping register.`, + `Schedule already registered for id ${id} (funder: ${existing.funder}). Skipping register.`, ); } else { console.log("Registering JIT funding schedule on the poller..."); - const registerTx = await poller.register(ctx, { + const registerTx = await poller.register({ handler, funder: eoaTrader, owner: cowShed, + salt, staticInput, }); console.log("Register tx:", getExplorerUrl(CHAIN_ID, registerTx.hash)); From ef6adb8f450d69e793deacb976c6c252fb6dd8df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20Ron=C4=8Devi=C4=87?= <57319163+igorroncevic@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:02:39 +0200 Subject: [PATCH 4/4] fix: call pollFunds from JIT TWAP script (#15) --- src/const/gnosis.ts | 2 +- src/contracts/composable-cow-poller/index.ts | 4 ++-- .../postTwapForEOAWithJitFunds.ts | 18 +++++++++--------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/const/gnosis.ts b/src/const/gnosis.ts index f406fb1..623eee9 100644 --- a/src/const/gnosis.ts +++ b/src/const/gnosis.ts @@ -2,7 +2,7 @@ export const GNO_ADDRESS = "0x9C58BAcC331c9aa871AFD802DB6379a98e80CEdb"; // ComposableCowPoller: just-in-time funding for composable conditional orders. // `id`-keyed deployment (schedule key is independent of the order's appData, so -// `topUp(id)` can be embedded as a pre-hook in the order's own appData). +// `pollFunds(id)` can be embedded as a pre-hook in the order's own appData). // See https://github.com/cowprotocol/composable-cow/pull/116 export const COMPOSABLE_COW_POLLER_ADDRESS = "0xA360eE11eD0d2025604518CF4B8F6e6CB76C7Df7"; diff --git a/src/contracts/composable-cow-poller/index.ts b/src/contracts/composable-cow-poller/index.ts index 214205d..807796f 100644 --- a/src/contracts/composable-cow-poller/index.ts +++ b/src/contracts/composable-cow-poller/index.ts @@ -4,7 +4,7 @@ import { ethers } from "ethers"; * Minimal ABI for the `ComposableCowPoller` contract. * * It enables just-in-time funding for composable conditional orders: instead of - * locking the whole notional up front, `topUp` pulls exactly the current discrete + * locking the whole notional up front, `pollFunds` pulls exactly the current discrete * order's `sellAmount` from a funder into the order owner, immediately before that * order settles. * @@ -15,7 +15,7 @@ const COMPOSABLE_COW_POLLER_ABI = [ "function scheduleId(address funder, address handler, address owner, bytes32 salt) external pure returns (bytes32)", "function register((address handler, address funder, address owner, bytes32 salt, bytes staticInput) schedule) external returns (bytes32 id)", "function revoke(bytes32 id) external", - "function topUp(bytes32 id) external", + "function pollFunds(bytes32 id) external", "function schedules(bytes32 id) external view returns (address handler, address funder, address owner, bytes32 salt, bytes staticInput)", "function lastFunded(bytes32 id) external view returns (bytes32)", ] as const; diff --git a/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts b/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts index 487589d..cde7762 100644 --- a/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts +++ b/src/scripts/composable-cow/postTwapForEOAWithJitFunds.ts @@ -44,8 +44,8 @@ const FIRST_ORDER_SLIPPAGE_BPS = 20000000000; // 200,000,000% // TODO: This was // The TWAP handler (ComposableCoW order type). Deterministic across chains. const TWAP_HANDLER = "0x6cF1e9cA41f7611dEf408122793c358a3d11E5a5"; -// Gas budget for the topUp pre-hook on each part (SLOADs + getTradeableOrder + transferFrom). -const TOPUP_HOOK_GAS_LIMIT = "350000"; +// Gas budget for the pollFunds pre-hook on each part (SLOADs + getTradeableOrder + transferFrom). +const POLL_FUNDS_HOOK_GAS_LIMIT = "350000"; const CHAIN_ID = SupportedChainId.GNOSIS_CHAIN; @@ -92,7 +92,7 @@ export async function run() { // The poller schedule key. It is derived from appData-INDEPENDENT fields // (funder, handler, owner, salt), which is exactly what lets us embed - // `topUp(id)` as a pre-hook inside the TWAP's own appData: the order's `ctx` + // `pollFunds(id)` as a pre-hook inside the TWAP's own appData: the order's `ctx` // contains the appData hash, so keying on `ctx` would be circular, but `id` // is not. We choose the salt, so we can compute `id` before the appData. const poller = getComposableCowPollerContract( @@ -125,25 +125,25 @@ The EOA keeps the 1 wei of ${twapSellToken.symbol}, which means that the EOA is The order will have the side-effects described above. Watch Tower will detect the TWAP and create each part, which will settle and send the proceeds back to the EOA. -Each part carries a pre-hook (baked into the TWAP appData) that calls poller.topUp(id), pulling exactly that part's +Each part carries a pre-hook (baked into the TWAP appData) that calls poller.pollFunds(id), pulling exactly that part's sell amount from the EOA into cow-shed right before it settles. No external keeper is needed. `, ); // Generate app data for the TWAP, embedding a pre-hook with the polling const metadataApi = new MetadataApi(); - const topUpCalldata = poller.interface.encodeFunctionData("topUp", [id]); + const pollFundsCalldata = poller.interface.encodeFunctionData("pollFunds", [id]); const twapAppData = await metadataApi.generateAppDataDoc({ appCode: APP_CODE, environment: "prod", metadata: { hooks: { pre: [ - // Call: poll.topUp(id) + // Call: poll.pollFunds(id) { target: COMPOSABLE_COW_POLLER_ADDRESS, - callData: topUpCalldata, - gasLimit: TOPUP_HOOK_GAS_LIMIT, + callData: pollFundsCalldata, + gasLimit: POLL_FUNDS_HOOK_GAS_LIMIT, }, ], }, @@ -208,7 +208,7 @@ TWAP buy amount total: ~${fmt(expectedTwapBuyAmount)} expected, ${fmt(twapBuyAmo const { handler, salt, staticInput } = twap.leaf; // Sanity: the handler/salt must be exactly what we derived `id` from, so the - // `topUp(id)` hook baked into the appData resolves to this very schedule. + // `pollFunds(id)` hook baked into the appData resolves to this very schedule. if ( handler.toLowerCase() !== TWAP_HANDLER.toLowerCase() || salt.toLowerCase() !== twapSalt.toLowerCase()