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..7656c34 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,88 @@ 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 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, uint256 expectedSentWeth, + uint256 leaveDust, string memory message ) internal { vm.deal(address(settlement), ethBalance); @@ -271,36 +345,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/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/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 aab0d9c..494ea48 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, })) @@ -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/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..0241c9f 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, })); @@ -60,6 +60,7 @@ export const swapTokens = async ( signer: signerWithProvider, toApprove, toDrip, + leaveDust: config.leaveDust, confirmDrip: config.confirmDrip, maxFeePerGas: config.maxFeePerGas, maxPriorityFeePerGas: config.maxPriorityFeePerGas, 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, ];