From 2f478d1ad977aaa3554a4359f6affee93f171bac Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:19:08 +0900 Subject: [PATCH 1/3] Leave configurable dust in contract when swapping tokens Subtracts a small amount (default 10 wei, configurable via --leave-dust) from each token's balance before quoting and submitting swap orders, so the settlement contract is never fully drained of any token. Co-Authored-By: Claude Sonnet 4.6 --- ts/config.ts | 1 + ts/drip/dripItAll.ts | 2 +- ts/drip/getTokensToSwap.ts | 6 +++--- ts/drip/postOrders.ts | 2 +- ts/drip/swapTokens.ts | 2 +- ts/utils/readConfig.ts | 10 ++++++++++ 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/ts/config.ts b/ts/config.ts index b437de4..600afab 100644 --- a/ts/config.ts +++ b/ts/config.ts @@ -104,4 +104,5 @@ export interface IConfig { tokenListStrategy: "explorer" | "chain"; lookbackRange: number; confirmDrip: boolean; + leaveDust: BigNumber; } diff --git a/ts/drip/dripItAll.ts b/ts/drip/dripItAll.ts index aab0d9c..9b3aa38 100644 --- a/ts/drip/dripItAll.ts +++ b/ts/drip/dripItAll.ts @@ -25,7 +25,7 @@ export async function dripItAll( tokensToSwap.map((token) => ({ symbol: token.symbol, address: token.address, - balance: formatUnits(token.balance, token.decimals), + balance: formatUnits(token.adjustedBalance, token.decimals), buyAmount: formatUnits(token.buyAmount, WETH_DECIMALS), needsApproval: token.needsApproval, })) diff --git a/ts/drip/getTokensToSwap.ts b/ts/drip/getTokensToSwap.ts index 92e3001..5ded59c 100644 --- a/ts/drip/getTokensToSwap.ts +++ b/ts/drip/getTokensToSwap.ts @@ -12,7 +12,7 @@ import { getAllowances } from "./getAllowances"; export interface GetTokensToSwapResult { buyAmount: BigNumber; - balance: BigNumber; + adjustedBalance: BigNumber; allowance: BigNumber; needsApproval: boolean; address: string; @@ -45,7 +45,7 @@ export async function getTokensToSwap( // minValue filter again with _real_ balance const unfilteredWithBalanceAndAllowance = unfiltered.map((token, idx) => ({ ...token, - balance: balances[idx], + adjustedBalance: balances[idx].sub(config.leaveDust), allowance: allowances[idx], needsApproval: allowances[idx].lt(balances[idx]), })); @@ -56,7 +56,7 @@ export async function getTokensToSwap( unfilteredWithBalanceAndAllowance.map((token) => orderBookApi.getQuote({ sellToken: token.address, - sellAmountBeforeFee: token.balance.toString(), + sellAmountBeforeFee: token.adjustedBalance.toString(), kind: OrderQuoteSideKindSell.SELL, buyToken: config.wrappedNativeToken, from: config.gpv2Settlement, diff --git a/ts/drip/postOrders.ts b/ts/drip/postOrders.ts index 2268782..6a12e2d 100644 --- a/ts/drip/postOrders.ts +++ b/ts/drip/postOrders.ts @@ -22,7 +22,7 @@ export async function postOrders( orderBookApi.sendOrder({ sellToken: token.address, buyToken: config.wrappedNativeToken, - sellAmount: token.balance.toString(), + sellAmount: token.adjustedBalance.toString(), buyAmount: token.buyAmount.toString(), validTo: nextValidTo, appData: appDataContent, diff --git a/ts/drip/swapTokens.ts b/ts/drip/swapTokens.ts index f3bb878..780e156 100644 --- a/ts/drip/swapTokens.ts +++ b/ts/drip/swapTokens.ts @@ -50,7 +50,7 @@ export const swapTokens = async ( const toDrip = toActuallySwap.map((token) => ({ token: token.address, - sellAmount: token.balance, + sellAmount: token.adjustedBalance, buyAmount: token.buyAmount, })); diff --git a/ts/utils/readConfig.ts b/ts/utils/readConfig.ts index 89c6d4d..0c9a707 100644 --- a/ts/utils/readConfig.ts +++ b/ts/utils/readConfig.ts @@ -72,6 +72,14 @@ export async function readConfig(): Promise< "-c, --confirm-drip", "Ask for confirmation before dripping" ).default(false) + ) + .addOption( + new Option( + "--leave-dust ", + "Amount of each token (in wei) to leave in the contract after swapping" + ) + .default(BigNumber.from(10)) + .argParser((x) => BigNumber.from(x)) ); program.parse(); @@ -88,6 +96,7 @@ export async function readConfig(): Promise< lookbackRange, tokenListStrategy, confirmDrip, + leaveDust, } = options; const network = selectedNetwork || "mainnet"; @@ -147,6 +156,7 @@ export async function readConfig(): Promise< lookbackRange, targetSafe, confirmDrip, + leaveDust, }, provider, ]; From f35157a01d56d2d19c2d0177a8357118cc68e594 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:41:30 +0900 Subject: [PATCH 2/3] Add _leaveDust param to drip() and leave dust for ETH/WETH Adds a `_leaveDust` parameter to the contract's `drip()` function so the keeper can specify how much native token (ETH) and wrapped native token (WETH) to retain in the settlement contract after each drip. Updates the TypeScript keeper to pass `config.leaveDust` through to the contract call, and adds tests covering both the WETH-only and ETH-only dust cases. Co-Authored-By: Claude Sonnet 4.6 --- src/COWFeeModule.sol | 11 +++---- test/COWFeeModule.t.sol | 63 ++++++++++++++++++++++++++++------------- ts/drip/drip.ts | 3 ++ ts/drip/dripItAll.ts | 1 + ts/drip/swapTokens.ts | 1 + 5 files changed, 55 insertions(+), 24 deletions(-) diff --git a/src/COWFeeModule.sol b/src/COWFeeModule.sol index 52fe07e..5a31fe4 100644 --- a/src/COWFeeModule.sol +++ b/src/COWFeeModule.sol @@ -89,20 +89,21 @@ contract COWFeeModule { /// @notice Commit presignatures for sell orders of given tokens of given amounts. /// Optionally, also approve the tokens to be spent to the vault relayer. - function drip(address[] calldata _approveTokens, SwapToken[] calldata _swapTokens) external onlyKeeper { + function drip(address[] calldata _approveTokens, SwapToken[] calldata _swapTokens, uint256 _leaveDust) external onlyKeeper { // Get native token balance. // We wrap the native token, as long as its above the minOut (requires an additional interaction) uint256 nativeBalance = address(settlement).balance; - bool hasToWrapNativeToken = nativeBalance >= minOut; + uint256 nativeToWrap = nativeBalance > _leaveDust ? nativeBalance - _leaveDust : 0; + bool hasToWrapNativeToken = nativeToWrap >= minOut; // Wrapped native token is handled differently because it's the buyToken, we just do a normal transfer (requires an additional interaction) // We account for the native balance (only if we wrap it) IWrappedNativeToken wrappedNativeTokenContract = IWrappedNativeToken(wrappedNativeToken); uint256 wrappedNativeBalance = - (hasToWrapNativeToken ? nativeBalance : 0) + wrappedNativeTokenContract.balanceOf(address(settlement)); + (hasToWrapNativeToken ? nativeToWrap : 0) + wrappedNativeTokenContract.balanceOf(address(settlement)); // Determine if we need a wrappedNativeToken transfer interaction - bool hasToTransferWrappedNativeToken = wrappedNativeBalance >= minOut; + bool hasToTransferWrappedNativeToken = wrappedNativeBalance > _leaveDust && wrappedNativeBalance - _leaveDust >= minOut; uint256 len = _approveTokens.length + _swapTokens.length + (hasToWrapNativeToken ? 2 : (hasToTransferWrappedNativeToken ? 1 : 0)); @@ -158,7 +159,7 @@ contract COWFeeModule { approveAndDripInteractions[len - 1] = IGPv2Settlement.InteractionData({ to: wrappedNativeToken, value: 0, - callData: abi.encodeCall(IERC20.transfer, (receiver, wrappedNativeBalance)) + callData: abi.encodeCall(IERC20.transfer, (receiver, wrappedNativeBalance - _leaveDust)) }); } diff --git a/test/COWFeeModule.t.sol b/test/COWFeeModule.t.sol index c4d7776..9506817 100644 --- a/test/COWFeeModule.t.sol +++ b/test/COWFeeModule.t.sol @@ -61,7 +61,7 @@ contract COWFeeModuleTest is Test { COWFeeModule.SwapToken[] memory swapTokens = new COWFeeModule.SwapToken[](0); address[] memory approveTokens = new address[](0); vm.expectRevert(COWFeeModule.OnlyKeeper.selector); - module.drip(approveTokens, swapTokens); + module.drip(approveTokens, swapTokens, 0); } function testApprove() external { @@ -130,7 +130,7 @@ contract COWFeeModuleTest is Test { vm.recordLogs(); vm.prank(keeper); - module.drip(approveTokens, swapTokens); + module.drip(approveTokens, swapTokens, 0); Vm.Log[] memory logs = vm.getRecordedLogs(); bool found = false; @@ -168,7 +168,7 @@ contract COWFeeModuleTest is Test { vm.prank(keeper); vm.expectRevert(COWFeeModule.BuyAmountTooSmall.selector); - module.drip(approveTokens, swapTokens); + module.drip(approveTokens, swapTokens, 0); } function testDripWeth() external { @@ -181,21 +181,21 @@ contract COWFeeModuleTest is Test { // WHEN: drip is called // THEN: WETH balance is increased by wethBalance uint256 expectedSentWeth = minOut; - dripAndAssertBalances(ethBalance, wethBalance, expectedSentWeth, "drip didn't transfer weth as expected"); + dripAndAssertBalances(ethBalance, wethBalance, expectedSentWeth, 0, "drip didn't transfer weth as expected"); } function testDripNotEnoughWeth() external { // GIVEN: Ether balance is 0 uint256 ethBalance = 0; - // GIVEN: WETH balance is minOut + // GIVEN: WETH balance is minOut - 1 uint256 wethBalance = minOut - 1; // WHEN: drip is called // THEN: WETH balance doesn't change uint256 expectedSentWeth = 0; dripAndAssertBalances( - ethBalance, wethBalance, expectedSentWeth, "drip modified WETH balance when no transfer was expected" + ethBalance, wethBalance, expectedSentWeth, 0, "drip modified WETH balance when no transfer was expected" ); } @@ -210,7 +210,7 @@ contract COWFeeModuleTest is Test { // THEN: WETH balance is increased by ethBalance uint256 expectedSentWeth = ethBalance; dripAndAssertBalances( - ethBalance, wethBalance, expectedSentWeth, "drip didn't wrap Ether and transfer WETH as expected" + ethBalance, wethBalance, expectedSentWeth, 0, "drip didn't wrap Ether and transfer WETH as expected" ); } @@ -225,7 +225,7 @@ contract COWFeeModuleTest is Test { // THEN: WETH balance doesn't change uint256 expectedSentWeth = 0; dripAndAssertBalances( - ethBalance, wethBalance, expectedSentWeth, "drip modified WETH balance when no transfer was expected" + ethBalance, wethBalance, expectedSentWeth, 0, "drip modified WETH balance when no transfer was expected" ); } @@ -233,14 +233,14 @@ contract COWFeeModuleTest is Test { // GIVEN: Ether balance is 0 uint256 ethBalance = minOut - 1; - // GIVEN: WETH balance is minOut + // GIVEN: WETH balance is minOut - 1 uint256 wethBalance = minOut - 1; // WHEN: drip is called // THEN: WETH balance doesn't change uint256 expectedSentWeth = 0; dripAndAssertBalances( - ethBalance, wethBalance, expectedSentWeth, "drip modified WETH balance when no transfer was expected" + ethBalance, wethBalance, expectedSentWeth, 0, "drip modified WETH balance when no transfer was expected" ); } @@ -255,14 +255,43 @@ contract COWFeeModuleTest is Test { // THEN: WETH balance is increased by 2 * minOut uint256 expectedSentWeth = 2 * minOut; dripAndAssertBalances( - ethBalance, wethBalance, expectedSentWeth, "drip modified WETH balance by wrong amount (expected 2x minOut)" + ethBalance, wethBalance, expectedSentWeth, 0, "drip modified WETH balance by wrong amount (expected 2x minOut)" ); } + function testDripWethLeaveDust() external { + uint256 leaveDust = 100; + + // GIVEN: Ether balance is 0, WETH balance is minOut + leaveDust + uint256 ethBalance = 0; + uint256 wethBalance = minOut + leaveDust; + + // WHEN: drip is called with leaveDust + // THEN: receiver gets wethBalance - leaveDust, settlement retains leaveDust WETH + uint256 expectedSentWeth = wethBalance - leaveDust; + dripAndAssertBalances(ethBalance, wethBalance, expectedSentWeth, leaveDust, "drip didn't leave WETH dust"); + } + + function testDripEthLeaveDust() external { + uint256 leaveDust = 100; + + // GIVEN: Ether balance is minOut + 2 * leaveDust, WETH balance is 0 + // (need enough ETH so that after leaving leaveDust ETH, the wrapped amount still covers minOut + leaveDust for WETH) + uint256 ethBalance = minOut + 2 * leaveDust; + uint256 wethBalance = 0; + + // nativeToWrap = ethBalance - leaveDust = minOut + leaveDust + // wrappedNativeBalance = minOut + leaveDust + // expectedSentWeth = wrappedNativeBalance - leaveDust = minOut + uint256 expectedSentWeth = minOut; + dripAndAssertBalances(ethBalance, wethBalance, expectedSentWeth, leaveDust, "drip didn't leave ETH and WETH dust"); + } + function dripAndAssertBalances( uint256 ethBalance, uint256 wethBalance, uint256 expectedSentWeth, + uint256 leaveDust, string memory message ) internal { vm.deal(address(settlement), ethBalance); @@ -271,36 +300,32 @@ contract COWFeeModuleTest is Test { address[] memory approveTokens = new address[](0); COWFeeModule.SwapToken[] memory swapTokens = new COWFeeModule.SwapToken[](0); - // Assert WETH balance change in receiver - assertWethBalanceChangeAfterDrip(expectedSentWeth, approveTokens, swapTokens, message); + assertWethBalanceChangeAfterDrip(expectedSentWeth, approveTokens, swapTokens, leaveDust, message); } function assertWethBalanceChangeAfterDrip( uint256 expectedSentWeth, address[] memory approveTokens, COWFeeModule.SwapToken[] memory swapTokens, + uint256 leaveDust, string memory message ) internal { - // Get balances before drip uint256 settlementEthBefore = address(settlement).balance; uint256 settlementWethBefore = IERC20(WETH).balanceOf(address(settlement)); uint256 receiverEthBefore = address(receiver).balance; uint256 receiverWethBefore = IERC20(WETH).balanceOf(receiver); - // Placeholder for the function call that would trigger balance changes vm.prank(keeper); - module.drip(approveTokens, swapTokens); + module.drip(approveTokens, swapTokens, leaveDust); - // Get balances after drip uint256 settlementEthAfter = address(settlement).balance; uint256 settlementWethAfter = IERC20(WETH).balanceOf(address(settlement)); uint256 receiverEthAfter = address(receiver).balance; uint256 receiverWethAfter = IERC20(WETH).balanceOf(receiver); - // Assert receiver's WETH balance increased by the expected amount assertEq(receiverWethAfter - receiverWethBefore, expectedSentWeth, message); - // Verify conservation of ETH/WETh + // Verify conservation of ETH/WETH across settlement and receiver assertEq( settlementEthBefore + settlementWethBefore + receiverEthBefore + receiverWethBefore, settlementEthAfter + settlementWethAfter + receiverEthAfter + receiverWethAfter, diff --git a/ts/drip/drip.ts b/ts/drip/drip.ts index 0f7c3b4..4ab01b0 100644 --- a/ts/drip/drip.ts +++ b/ts/drip/drip.ts @@ -11,6 +11,7 @@ export interface DripParams { signer: ethers.Signer; toApprove: string[]; toDrip: { token: string; sellAmount: BigNumber; buyAmount: BigNumber }[]; + leaveDust: BigNumber; confirmDrip: boolean; maxFeePerGas?: BigNumber; maxPriorityFeePerGas?: BigNumber; @@ -50,6 +51,7 @@ async function getDripTx(params: DripParams): Promise { signer: signerWithProvider, toApprove, toDrip, + leaveDust, } = params; // On Gnosis chain we ran into an error where ethers would choose a nonce that was way too high @@ -70,6 +72,7 @@ async function getDripTx(params: DripParams): Promise { data: moduleContract.interface.encodeFunctionData("drip", [ toApprove, toDrip, + leaveDust, ]), value: BigNumber.from(0), nonce, diff --git a/ts/drip/dripItAll.ts b/ts/drip/dripItAll.ts index 9b3aa38..494ea48 100644 --- a/ts/drip/dripItAll.ts +++ b/ts/drip/dripItAll.ts @@ -51,6 +51,7 @@ export async function dripItAll( signer, toApprove: [], toDrip: [], + leaveDust: config.leaveDust, maxFeePerGas: config.maxFeePerGas, maxPriorityFeePerGas: config.maxPriorityFeePerGas, confirmDrip: config.confirmDrip, diff --git a/ts/drip/swapTokens.ts b/ts/drip/swapTokens.ts index 780e156..0241c9f 100644 --- a/ts/drip/swapTokens.ts +++ b/ts/drip/swapTokens.ts @@ -60,6 +60,7 @@ export const swapTokens = async ( signer: signerWithProvider, toApprove, toDrip, + leaveDust: config.leaveDust, confirmDrip: config.confirmDrip, maxFeePerGas: config.maxFeePerGas, maxPriorityFeePerGas: config.maxPriorityFeePerGas, From 3c3af761d2443567bd2e515a64490b01ed456a3a Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:52:46 +0900 Subject: [PATCH 3/3] Add leaveDust tests including edge cases for insufficient balance Co-Authored-By: Claude Sonnet 4.6 --- test/COWFeeModule.t.sol | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/COWFeeModule.t.sol b/test/COWFeeModule.t.sol index 9506817..7656c34 100644 --- a/test/COWFeeModule.t.sol +++ b/test/COWFeeModule.t.sol @@ -287,6 +287,51 @@ contract COWFeeModuleTest is Test { dripAndAssertBalances(ethBalance, wethBalance, expectedSentWeth, leaveDust, "drip didn't leave ETH and WETH dust"); } + function testDripWethExactlyLeaveDust() external { + uint256 leaveDust = 100; + + // GIVEN: WETH balance equals leaveDust exactly + // WHEN: drip is called + // THEN: nothing is transferred — guard (wrappedNativeBalance > leaveDust) is false + dripAndAssertBalances(0, leaveDust, 0, leaveDust, "drip should not transfer when weth == leaveDust"); + } + + function testDripWethBelowLeaveDust() external { + uint256 leaveDust = 100; + + // GIVEN: WETH balance is below leaveDust + // WHEN: drip is called + // THEN: nothing is transferred — guard prevents underflow + dripAndAssertBalances(0, leaveDust - 1, 0, leaveDust, "drip should not transfer when weth < leaveDust"); + } + + function testDripEthExactlyLeaveDust() external { + uint256 leaveDust = 100; + + // GIVEN: ETH balance equals leaveDust, no WETH + // WHEN: drip is called + // THEN: nativeToWrap == 0 so nothing wraps or transfers + dripAndAssertBalances(leaveDust, 0, 0, leaveDust, "drip should not wrap when eth == leaveDust"); + } + + function testDripEthBelowLeaveDust() external { + uint256 leaveDust = 100; + + // GIVEN: ETH balance is below leaveDust, no WETH + // WHEN: drip is called + // THEN: ternary clamps nativeToWrap to 0, nothing wraps or transfers + dripAndAssertBalances(leaveDust - 1, 0, 0, leaveDust, "drip should not wrap when eth < leaveDust"); + } + + function testDripWethNotEnoughAboveDust() external { + uint256 leaveDust = 100; + + // GIVEN: WETH balance is leaveDust + minOut - 1 (above dust but transfer amount would be minOut - 1) + // WHEN: drip is called + // THEN: nothing is transferred — wrappedNativeBalance - leaveDust < minOut + dripAndAssertBalances(0, leaveDust + minOut - 1, 0, leaveDust, "drip should not transfer when amount above dust is below minOut"); + } + function dripAndAssertBalances( uint256 ethBalance, uint256 wethBalance,