Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/COWFeeModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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))
});
}

Expand Down
108 changes: 89 additions & 19 deletions test/COWFeeModule.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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"
);
}

Expand All @@ -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"
);
}

Expand All @@ -225,22 +225,22 @@ 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"
);
}

function testDripNotEnoughEthNorWeth() external {
// 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"
);
}

Expand All @@ -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);
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions ts/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,5 @@ export interface IConfig {
tokenListStrategy: "explorer" | "chain";
lookbackRange: number;
confirmDrip: boolean;
leaveDust: BigNumber;
}
3 changes: 3 additions & 0 deletions ts/drip/drip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,6 +51,7 @@ async function getDripTx(params: DripParams): Promise<TransactionRequest> {
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
Expand All @@ -70,6 +72,7 @@ async function getDripTx(params: DripParams): Promise<TransactionRequest> {
data: moduleContract.interface.encodeFunctionData("drip", [
toApprove,
toDrip,
leaveDust,
]),
value: BigNumber.from(0),
nonce,
Expand Down
3 changes: 2 additions & 1 deletion ts/drip/dripItAll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
Expand All @@ -51,6 +51,7 @@ export async function dripItAll(
signer,
toApprove: [],
toDrip: [],
leaveDust: config.leaveDust,
maxFeePerGas: config.maxFeePerGas,
maxPriorityFeePerGas: config.maxPriorityFeePerGas,
confirmDrip: config.confirmDrip,
Expand Down
6 changes: 3 additions & 3 deletions ts/drip/getTokensToSwap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { getAllowances } from "./getAllowances";

export interface GetTokensToSwapResult {
buyAmount: BigNumber;
balance: BigNumber;
adjustedBalance: BigNumber;
allowance: BigNumber;
needsApproval: boolean;
address: string;
Expand Down Expand Up @@ -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]),
}));
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion ts/drip/postOrders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion ts/drip/swapTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));

Expand All @@ -60,6 +60,7 @@ export const swapTokens = async (
signer: signerWithProvider,
toApprove,
toDrip,
leaveDust: config.leaveDust,
confirmDrip: config.confirmDrip,
maxFeePerGas: config.maxFeePerGas,
maxPriorityFeePerGas: config.maxPriorityFeePerGas,
Expand Down
10 changes: 10 additions & 0 deletions ts/utils/readConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ export async function readConfig(): Promise<
"-c, --confirm-drip",
"Ask for confirmation before dripping"
).default(false)
)
.addOption(
new Option(
"--leave-dust <wei>",
"Amount of each token (in wei) to leave in the contract after swapping"
)
.default(BigNumber.from(10))
.argParser((x) => BigNumber.from(x))
);

program.parse();
Expand All @@ -88,6 +96,7 @@ export async function readConfig(): Promise<
lookbackRange,
tokenListStrategy,
confirmDrip,
leaveDust,
} = options;
const network = selectedNetwork || "mainnet";

Expand Down Expand Up @@ -147,6 +156,7 @@ export async function readConfig(): Promise<
lookbackRange,
targetSafe,
confirmDrip,
leaveDust,
},
provider,
];
Expand Down
Loading