diff --git a/README.md b/README.md index 55238f3a..6b68e067 100644 --- a/README.md +++ b/README.md @@ -131,38 +131,7 @@ This means that any solver could drain the fee amount from the user until not en We recommend to never sign orders of this form and, if developing a contract that creates orders on behalf of other users, make sure at a contract level that such orders cannot be created. -## Helper scripts - -A collection of tools for interacting with the CoW Swap contracts. - -### Solver Authentication - -This repo contains scripts to manage the list of authenticated solvers in all networks the contract has been deployed. - -The scripts are called with: - -```sh -yarn solvers command [arg ...] -``` - -Here is a list of available commands. -The commands flagged with [**] require exporting the private key of the authentication contract owner, while those flagged with [*] require the address of either the owner or the manager. -The private key can be exported with `export PK=`. - -1. `add $ADDRESS` [*]. Adds the address to the list of registered solvers. -2. `remove $ADDRESS` [*]. Removes the address from the list of registered solvers. -3. `check $ADDRESS`. Checks if the given address is in the list of registered solvers. -3. `list`. Lists all registered solvers. -3. `setManager $ADDRESS` [**]. Sets the manager of the authenticator to the input address. - -For example, adding the address `0x0000000000000000000000000000000000000042` to the solver list: - -```sh -export PK= -yarn solvers add 0x0000000000000000000000000000000000000042 -``` - -### Transfer Ownership +## Transfer Ownership There is a dedicated script to change the owner of the authenticator proxy. @@ -172,38 +141,6 @@ Usage and parameters can be seen by running: yarn hardhat transfer-ownership --help ``` -### Fee Withdrawals - -Script to withdraw all balances of the Settlement contract. Allows to specify what minimum value the contract must have for a token to be considered (breadcrumbs might not be worth the gas costs) and how much remaining value should be left in the contract (e.g. to feed token buffers). - -If no token list is passed in all traded token balances will be fetched from chain (can take a long time...) - -```sh -export PK= -yarn hardhat withdraw --receiver 0x6C2999B6B1fAD608ECEA71B926D68Ee6c62BeEf8 --min-value 10000 --leftover 500 0x038a68ff68c393373ec894015816e33ad41bd564 0x913d8adf7ce6986a8cbfee5a54725d9eea4f0729 -``` - -### Decoding Settlement CallData - -This project exposes some handy scripts for parsing settlement calldata into human readable format. - -The `decode` script can be used in two ways: - -1. By specifying the transaction hash of an existing settlement transaction `--txhash 0x...` - -```sh -npx hardhat decode --txhash 0xc12e5bc2ef9c116932301495738d555ea1d658977dacd6c7989a6d77125a17d2 --network mainnet -``` - -2. When no `txhash` is specified, by reading the calldata from stdin (`< calldata.txt`). If stdin is a terminal, the user is prompted to paste the calldata into the terminal. - -```sh -> npx hardhat decode --network mainnet -# Paste in the calldata to decode -``` - -Note that you will be expected to have your `INFURA_KEY` exported to your environment variables. - ## Releases The content of this repo is published on NPM as [`@cowprotocol/contracts`](https://www.npmjs.com/package/@cowprotocol/contracts). diff --git a/src/tasks/README.md b/src/tasks/README.md deleted file mode 100644 index 331fbcab..00000000 --- a/src/tasks/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Hardhat tasks - -Below a description of how to run some of the scripts in this folder: - -## setApprovals - -Set PK and NODE_URL env variables. Create a json with with a list of desired allowances (use 0 to revoke allowance) such as: - -```json -[ - { - "spender": "0x9008d19f58aabd9ed0d60971565aa8510560ab41", - "token": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - "amount": "0" - } -] -``` - -Then run - -``` -yarn hardhat set-approvals --network --gas-in-gwei [--dry-run] -``` - -## selfSell - -Creates partially fillable limit orders in the name of the settlement contract, which can be used to swap and withdraw accrued fees or internal buffers. - -Set PK and NODE_URL env variables. Then run something like - -``` -npx hardhat self-sell \ - --network mainnet \ - --receiver 0xA03be496e67Ec29bC62F01a428683D7F9c204930 \ - --to-token 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 \ - --min-value 900 \ - --leftover 100 \ - --fee-slippage-bps 10000 \ - --price-slippage-bps 500 \ - --max-fee-percent 10 \ - --validity 7200 \ - --api-url "https://api.cow.fi/mainnet" -``` - -if the transaction is initiated by a Safe, add the `origin
` and `safe` flag to make it automatically propose a tranasction (`PK` needs to be a signer on the safe). If you specify `--notify-slack-channel ` it will also send a slack message asking for signatures (`SLACK_TOKEN` needs to be exported as an env variable). diff --git a/src/tasks/artifacts.ts b/src/tasks/artifacts.ts deleted file mode 100644 index b3863ac9..00000000 --- a/src/tasks/artifacts.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { promises as fs } from "fs"; -import path from "path"; - -import globby from "globby"; -import { TASK_COMPILE_SOLIDITY_EMIT_ARTIFACTS } from "hardhat/builtin-tasks/task-names"; -import { subtask } from "hardhat/config"; - -const projectRoot = path.join(__dirname, "../.."); -const artifactsRoot = path.join(projectRoot, "build/artifacts/src/contracts"); -const exportedArtifactsRoot = path.join(projectRoot, "lib/contracts"); - -async function copyArtifacts(): Promise { - const artifacts = await globby(["**/*.json", "!**/*.dbg.json", "!test/"], { - cwd: artifactsRoot, - }); - - await fs.mkdir(exportedArtifactsRoot, { recursive: true }); - for (const artifact of artifacts) { - const { base } = path.parse(artifact); - - const artifactPath = path.join(artifactsRoot, artifact); - const exportedArtifactPath = path.join(exportedArtifactsRoot, base); - await fs.copyFile(artifactPath, exportedArtifactPath); - } -} - -export function setupCopyArtifactsTask(): void { - subtask(TASK_COMPILE_SOLIDITY_EMIT_ARTIFACTS).setAction( - async (_args, _hre, runSuper) => { - const result = await runSuper(); - await copyArtifacts(); - return result; - }, - ); -} diff --git a/src/tasks/decode.ts b/src/tasks/decode.ts deleted file mode 100644 index 6388df70..00000000 --- a/src/tasks/decode.ts +++ /dev/null @@ -1,456 +0,0 @@ -import "hardhat-deploy"; -import "@nomiclabs/hardhat-ethers"; - -import readline from "readline"; - -import chalk from "chalk"; -import { BigNumber, BigNumberish, utils, constants } from "ethers"; -import { task } from "hardhat/config"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; -import { Deployment } from "hardhat-deploy/types"; - -import { - decodeTradeFlags, - EncodedSettlement, - Trade, - BUY_ETH_ADDRESS, - Interaction, - SigningScheme, - decodeSignatureOwner, - TypedDataDomain, - domain, - computeOrderUid, - decodeOrder, - OrderBalance, -} from "../ts"; - -import { - decode as decodeInteraction, - DecodedInteraction, -} from "./decode/interaction"; -import { Align, displayTable } from "./ts/table"; -import { Erc20Token, erc20Token } from "./ts/tokens"; - -const WIDTH = 120; -const INVALID_TOKEN = " ! Invalid token ! "; -const INVALID_OWNER = " ! Invalid owner ! "; -const NATIVE_TOKEN = " native token "; - -interface Token extends Partial { - address: string; - nativeFlag: boolean; - price: BigNumber | undefined; - index: number; -} - -export interface DetailedInteraction extends Interaction { - decoded: DecodedInteraction; -} - -type MaybeToken = - | Token - | { - index: number; - decimals?: undefined; - symbol?: undefined; - nativeFlag?: undefined; - }; - -type FormatToken = { - address: string; - index: string; - symbol: string; - price: string; -}; - -function formatToken(token: Token): FormatToken { - return { - address: token.address, - index: token.index.toString(), - symbol: token.nativeFlag ? NATIVE_TOKEN : token.symbol ?? INVALID_TOKEN, - price: (token.price ?? "no price").toString(), - }; -} - -const mainLabel = (s: string) => chalk.bold(chalk.yellow(s + ":")); -const label = (s: string) => chalk.cyan(s + ":"); - -function displayTokens(tokens: Token[]) { - const formattedTokens = tokens.map(formatToken); - const order = ["address", "index", "symbol", "price"]; - const header = { - address: "address", - index: "index", - symbol: "symbol", - price: "price", - }; - console.log(chalk.bold("=== Tokens ===")); - displayTable(header, formattedTokens, order, { - index: { align: Align.Right }, - symbol: { maxWidth: 20 }, - price: { align: Align.Right }, - }); - console.log(); -} - -function displayTrades( - trades: Trade[], - tokens: Token[], - domainSeparator: TypedDataDomain | null, -) { - console.log(chalk.bold("=== Trades ===")); - console.log(chalk.gray("-".repeat(WIDTH))); - for (const trade of trades) { - displayTrade(trade, tokens, domainSeparator); - console.log(); - } -} - -function formatSignature(sig: SigningScheme): string { - switch (sig) { - case SigningScheme.EIP712: - return "eip-712"; - case SigningScheme.ETHSIGN: - return "ethsign"; - case SigningScheme.EIP1271: - return "eip-1271"; - case SigningScheme.PRESIGN: - return "presign"; - default: - return `invalid (${sig})`; - } -} - -function displayTrade( - trade: Trade, - tokens: Token[], - domainSeparator: TypedDataDomain | null, -) { - const prettyToken = (token: MaybeToken, checkNative?: boolean) => - `${ - checkNative && token.nativeFlag - ? NATIVE_TOKEN - : token.symbol ?? INVALID_TOKEN - } (${token.index})`; - const prettyAmount = ( - amount: BigNumberish, - token: MaybeToken, - checkNative?: boolean, - ) => - `${utils.formatUnits(amount, token.decimals ?? undefined)} ${prettyToken( - token, - checkNative, - )}`; - const { - executedAmount, - validTo, - appData, - receiver, - sellAmount, - buyAmount, - feeAmount, - sellTokenIndex, - buyTokenIndex, - flags, - signature, - } = trade; - const { - kind, - partiallyFillable, - signingScheme, - buyTokenBalance, - sellTokenBalance, - } = decodeTradeFlags(flags); - let owner = null; - let orderUid = null; - if (domainSeparator !== null) { - try { - const order = decodeOrder( - trade, - tokens.map((token) => token.address), - ); - owner = decodeSignatureOwner( - domainSeparator, - order, - signingScheme, - signature, - ); - orderUid = computeOrderUid(domainSeparator, order, owner); - } catch { - // Nothing to do, `null` variables mark a decoding error. - } - } - const sellToken = tokens[BigNumber.from(sellTokenIndex).toNumber()] ?? { - index: sellTokenIndex, - }; - const buyToken = tokens[BigNumber.from(buyTokenIndex).toNumber()] ?? { - index: buyTokenIndex, - }; - console.log( - mainLabel("Order"), - `${kind.toString().toUpperCase()} ${ - partiallyFillable ? "partially fillable " : "" - }order, valid until ${new Date( - BigNumber.from(validTo).toNumber() * 1000, - ).toISOString()} (${validTo.toString()})`, - ); - console.log( - label(`Trade`), - `sell ${prettyAmount(sellAmount, sellToken)}` + - (sellTokenBalance !== OrderBalance.ERC20 - ? `, from Balancer ${sellTokenBalance} balance` - : ""), - ); - console.log( - " ", - ` buy ${prettyAmount(buyAmount, buyToken, true)}` + - (buyTokenBalance !== OrderBalance.ERC20 - ? `, to Balancer ${buyTokenBalance} balance` - : ""), - ); - console.log(" ", ` fee ${prettyAmount(feeAmount, sellToken)}`); - if (partiallyFillable) { - console.log( - label(`Executed amount`), - `${prettyAmount(executedAmount, sellToken)}`, - ); - } - if (domainSeparator !== null) { - console.log(label("Owner"), owner === null ? INVALID_OWNER : owner); - } - if (receiver !== constants.AddressZero) { - console.log(label(`Receiver`), receiver); - } - if (appData !== constants.HashZero) { - console.log(label(`AppData`), appData); - } - console.log( - label(`Signature (${formatSignature(signingScheme)})`), - signature, - ); - if (orderUid !== null) { - console.log(label("OrderUid"), orderUid); - } -} - -function displayInteractions( - interactions: [ - DetailedInteraction[], - DetailedInteraction[], - DetailedInteraction[], - ], -) { - console.log(chalk.bold("=== Interactions ===")); - console.log(chalk.gray("-".repeat(WIDTH))); - displayInteractionGroup("Pre-interactions", interactions[0]); - console.log(); - displayInteractionGroup("Intra-interactions", interactions[1]); - console.log(); - displayInteractionGroup("Post-interactions", interactions[2]); - console.log(); -} - -function displayInteractionGroup( - name: string, - interactions: DetailedInteraction[], -) { - const nonEmpty = interactions.length !== 0; - console.log(` ${nonEmpty ? "┌" : " "}--- ${name} ---`); - for (const interaction of interactions.slice(undefined, -1)) { - displayInteraction(interaction, false); - } - if (nonEmpty) { - displayInteraction(interactions[interactions.length - 1], true); - } -} - -function displayInteraction(interaction: DetailedInteraction, isLast: boolean) { - let newInteraction = true; - const branch = () => { - if (newInteraction) { - newInteraction = false; - return isLast ? " └─── " : " ├─── "; - } else { - return isLast ? " " : " │ "; - } - }; - const branchedLog = (...args: unknown[]) => console.log(branch(), ...args); - const { target, value, callData, decoded } = interaction; - branchedLog( - mainLabel("Interaction"), - `target address ${target}` + - ((decoded?.targetName ?? null) !== null - ? ` (${decoded.targetName})` - : ""), - ); - if (!BigNumber.from(value).isZero()) { - branchedLog(label("Value"), utils.formatEther(value)); - } - if ((decoded?.call ?? null) !== null) { - // `decoded?.call` is defined and not null by the if check - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { functionName, args } = decoded.call!; - if (args === null) { - branchedLog( - label("function"), - functionName, - chalk.red("(input decoding failed)"), - ); - } else { - branchedLog(label("function"), functionName); - for (const [name, value] of args) { - branchedLog(label(` - ${name}`), value); - } - } - } - branchedLog(label("calldata"), callData); -} - -async function calldataFromUserInput( - txhash: string, - deploymentPromise: Promise, - hre: HardhatRuntimeEnvironment, -): Promise { - const { ethers, network } = hre; - let calldata = null; - if (txhash !== undefined) { - const tx = await ethers.provider.getTransaction(txhash.trim()); - const deployment = await deploymentPromise; - if (tx === null) { - throw new Error(`Transaction not found on network ${network.name}`); - } - calldata = tx.data; - if (deployment === null || tx.to !== deployment.address) { - console.log( - `Warning: the input transaction hash does not point to an interaction with the current deployment of GPv2 settlement contract on ${network.name}.`, - ); - console.log(`Deployment: ${deployment?.address}`); - console.log(`Target: ${tx.to}`); - } - } else { - let output = undefined; - if (process.stdin.isTTY) { - console.log("Paste in the calldata to decode"); - // This line mitigates an issue where the terminal truncates pasted input - // calldata to 4096 character. It implicitly enables raw mode for stdin - // while keeping most terminal features enabled. - output = process.stdout; - } - const rl = readline.createInterface({ - input: process.stdin, - output, - }); - for await (const line of rl) { - const trimmed = line.trim(); - if (trimmed.length !== 0) { - calldata = trimmed; - break; - } - } - if (calldata === null) { - throw new Error("No input calldata provided"); - } - } - if (!/^0x[0-9a-f]*/.exec(calldata)) { - throw new Error("Invalid calldata"); - } - return calldata; -} - -const setupDecodeTask: () => void = () => { - task("decode", "Decodes GPv2 settlement calldata.") - .addOptionalParam( - "txhash", - "The transaction hash of the transaction to decode. If this flag is set, stdin is ignored.", - ) - .setAction(async ({ txhash }, hre) => { - const { artifacts, ethers, deployments } = hre; - const deploymentPromise = deployments - .get("GPv2Settlement") - .catch(() => null); - const calldata = await calldataFromUserInput( - txhash, - deploymentPromise, - hre, - ); - const { chainId } = await ethers.provider.getNetwork(); - const deployment = await deploymentPromise; - const domainSeparator = - deployment === null ? null : domain(chainId, deployment.address); - - const GPv2Settlement = await artifacts.readArtifact("GPv2Settlement"); - const settlementInterface = new utils.Interface(GPv2Settlement.abi); - - const [tokenAddresses, clearingPrices, trades, interactions] = - settlementInterface.decodeFunctionData( - "settle", - calldata, - ) as EncodedSettlement; - - const tokens = await Promise.all( - tokenAddresses.map(async (address: string, index: number) => { - const erc20 = await erc20Token(address, hre); - return { - ...(erc20 ?? {}), - address, - index, - nativeFlag: BUY_ETH_ADDRESS === address, - price: clearingPrices[index] as BigNumber | undefined, - }; - }), - ); - - displayTokens(tokens); - - if (clearingPrices.length > tokens.length) { - console.log( - `Warning: settlement has ${ - clearingPrices.length - tokens.length - } more prices than tokens.`, - ); - console.log(`Extra prices from index ${tokens.length}:`); - console.log( - clearingPrices.slice(tokens.length).map((price) => price.toString()), - ); - } - - displayTrades(trades, tokens, domainSeparator); - - const tokenRegistry: Record = {}; - tokens - .filter( - (token) => token.contract !== undefined && token.contract !== null, - ) - .forEach((token) => { - tokenRegistry[token.address] = { - address: token.address, - // Contract is defined because of the previous filter - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - contract: token.contract!, - symbol: token.symbol, - decimals: token.decimals, - }; - }); - const detailedInteractions = (await Promise.all( - interactions.map( - async (interactionGroup) => - await Promise.all( - interactionGroup.map(async (i) => ({ - ...i, - decoded: await decodeInteraction(i, hre, { - tokenRegistry, - settlementContractAddress: deployment?.address, - }), - })), - ), - ), - )) as [ - DetailedInteraction[], - DetailedInteraction[], - DetailedInteraction[], - ]; - displayInteractions(detailedInteractions); - }); -}; - -export { setupDecodeTask }; diff --git a/src/tasks/decode/interaction.ts b/src/tasks/decode/interaction.ts deleted file mode 100644 index 90c18734..00000000 --- a/src/tasks/decode/interaction.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { Interaction } from "../../ts"; -import { Erc20Token } from "../ts/tokens"; - -import { Erc20Decoder } from "./interaction/erc20"; -import { InteractionDecoder } from "./interaction/template"; -import { UniswapLikeDecoder } from "./interaction/uniswap_like"; - -// For reference, here is a list of contracts supported by the backend: -// https://github.com/gnosis/gp-v2-services/blob/a472d0dca02c0df30c22e17c959d37e2c828fed2/contracts/build.rs - -// Decoded calldata of a function call. -export interface DecodedInteractionCall { - functionName: string; - // args is null in case of a decoding error. - args: Map | null; -} - -export interface DecodedInteraction { - targetName: string; - // call is null in case the decoder does not support the function used in the - // interaction - call: DecodedInteractionCall | null; -} - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface DecodingTools { - tokenRegistry?: Record; - settlementContractAddress?: string; -} - -export async function decode( - interaction: Interaction, - hre: HardhatRuntimeEnvironment, - decodingTools: DecodingTools = {}, -): Promise { - const decoders: InteractionDecoder[] = [ - new UniswapLikeDecoder(hre), - new Erc20Decoder(hre), - ]; - - try { - // TODO: use Promise.any when better supported by our tooling - const decoded = ( - await Promise.allSettled( - decoders.map((decoder) => decoder.decode(interaction, decodingTools)), - ) - ).filter( - (result) => result.status === "fulfilled" && result.value !== null, - ) as PromiseFulfilledResult[]; - - return decoded[0]?.value ?? null; - } catch { - // no valid decoding found - return null; - } -} diff --git a/src/tasks/decode/interaction/erc20.ts b/src/tasks/decode/interaction/erc20.ts deleted file mode 100644 index 797a412d..00000000 --- a/src/tasks/decode/interaction/erc20.ts +++ /dev/null @@ -1,142 +0,0 @@ -import IERC20 from "@openzeppelin/contracts/build/contracts/IERC20.json"; -import { utils, BytesLike } from "ethers"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { Interaction } from "../../../ts"; -import { erc20Token } from "../../ts/tokens"; -import { - DecodedInteraction, - DecodedInteractionCall, - DecodingTools, -} from "../interaction"; - -import { InteractionDecoder } from "./template"; - -const erc20Interface = new utils.Interface(IERC20.abi); - -export class Erc20Decoder extends InteractionDecoder { - private hre: HardhatRuntimeEnvironment; - - constructor(hre: HardhatRuntimeEnvironment) { - super(); - this.hre = hre; - } - - public get name(): string { - return "erc20"; - } - - private formatCalldata( - calldata: BytesLike, - symbol: string, - decimals: number, - settlementContractAddress: string, - ): DecodedInteractionCall | null { - const selector = utils.hexlify(utils.arrayify(calldata).slice(0, 4)); - - let functionName: string; - let args: Map | null = null; - switch (selector) { - case erc20Interface.getSighash("approve"): - functionName = "approve"; - try { - const { spender, amount } = erc20Interface.decodeFunctionData( - functionName, - calldata, - ); - args = new Map(); - args.set( - "spender", - spender === settlementContractAddress - ? "settlement contract" - : spender, - ); - args.set( - "amount", - `${utils.formatUnits(amount, decimals)} ${symbol}`, - ); - } catch { - // unable to decode arguments, `args` is null - } - break; - case erc20Interface.getSighash("transfer"): - functionName = "transfer"; - try { - const { recipient, amount } = erc20Interface.decodeFunctionData( - functionName, - calldata, - ); - args = new Map(); - args.set( - "recipient", - recipient === settlementContractAddress - ? "settlement contract" - : recipient, - ); - args.set( - "amount", - `${utils.formatUnits(amount, decimals)} ${symbol}`, - ); - } catch { - // unable to decode arguments, `args` is null - } - break; - case erc20Interface.getSighash("transferFrom"): - functionName = "transferFrom"; - try { - const { sender, recipient, amount } = - erc20Interface.decodeFunctionData(functionName, calldata); - args = new Map(); - args.set( - "sender", - sender === settlementContractAddress - ? "settlement contract" - : sender, - ); - args.set( - "recipient", - recipient === settlementContractAddress - ? "settlement contract" - : recipient, - ); - args.set( - "amount", - `${utils.formatUnits(amount, decimals)} ${symbol}`, - ); - } catch { - // unable to decode arguments, `args` is null - } - break; - default: - return null; - } - - return { functionName, args }; - } - - public async decode( - interaction: Interaction, - decodingTools: DecodingTools = {}, - ): Promise { - const { settlementContractAddress } = decodingTools; - const { symbol, decimals } = - (await erc20Token(interaction.target, this.hre)) ?? {}; - // Assumption: it's a token if and only if it has both a symbol and - // decimals. In theory there could be false positives and negatives, in - // practice all meaningful tokens have both. - if (symbol === undefined || decimals === undefined) { - return null; - } - - const targetName = `erc20 token: ${symbol}`; - return { - targetName, - call: this.formatCalldata( - interaction.callData, - symbol, - decimals, - settlementContractAddress ?? "", - ), - }; - } -} diff --git a/src/tasks/decode/interaction/template.ts b/src/tasks/decode/interaction/template.ts deleted file mode 100644 index da479cca..00000000 --- a/src/tasks/decode/interaction/template.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Interaction } from "../../../ts"; -import { DecodedInteraction, DecodingTools } from "../interaction"; - -export abstract class InteractionDecoder { - // Returns a human-readable name representing which decoder was used. Examples - // could be "uniswap-like", "1inch", ... - public abstract get name(): string; - - // Tries to decode the input interaction. Returns `null` if and only if the - // interaction can't be decoded with this decoder instance. - public abstract decode( - interaction: Interaction, - decodingTools?: DecodingTools, - ): Promise; -} diff --git a/src/tasks/decode/interaction/uniswap_like.ts b/src/tasks/decode/interaction/uniswap_like.ts deleted file mode 100644 index d9802830..00000000 --- a/src/tasks/decode/interaction/uniswap_like.ts +++ /dev/null @@ -1,145 +0,0 @@ -import UniswapV2Router from "@uniswap/v2-periphery/build/UniswapV2Router02.json"; -import { utils, BytesLike, BigNumberish } from "ethers"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { Interaction } from "../../../ts"; -import { - DecodedInteraction, - DecodedInteractionCall, - DecodingTools, -} from "../interaction"; - -import { InteractionDecoder } from "./template"; - -const ROUTERS: Record> = { - rinkeby: { - // https://docs.uniswap.org/protocol/V2/reference/smart-contracts/router-02 (same as mainnet) - "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D": "Uniswap", - // https://dev.sushi.com/docs/Developers/Deployment Addresses (same as xdai) - "0x1b02dA8Cb0d097eB8D57A175b88c7D8b47997506": "Sushiswap", - }, - goerli: { - // https://docs.uniswap.org/protocol/V2/reference/smart-contracts/router-02 (same as mainnet) - "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D": "Uniswap", - // https://dev.sushi.com/docs/Developers/Deployment Addresses (same as xdai) - "0x1b02dA8Cb0d097eB8D57A175b88c7D8b47997506": "Sushiswap", - }, - mainnet: { - // https://docs.uniswap.org/protocol/V2/reference/smart-contracts/router-02 - "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D": "Uniswap", - // https://dev.sushi.com/docs/Developers/Deployment Addresses - "0xd9e1cE17f2641f24aE83637ab66a2cca9C378B9F": "Sushiswap", - }, - xdai: { - // https://wiki.1hive.org/projects/honeyswap/honeyswap-on-xdai - "0x1C232F01118CB8B424793ae03F870aa7D0ac7f77": "Honeyswap", - // https://dev.sushi.com/docs/Developers/Deployment Addresses - "0x1b02dA8Cb0d097eB8D57A175b88c7D8b47997506": "Sushiswap", - }, -}; - -const uniswapInterface = new utils.Interface(UniswapV2Router.abi); - -export class UniswapLikeDecoder extends InteractionDecoder { - private network: string; - - constructor(hre: HardhatRuntimeEnvironment) { - super(); - this.network = hre.network.name; - } - - public get name(): string { - return "uniswap-like"; - } - - // Returns the name of the Uniswap-like contract or null if the target is not - // decodable with this decoder. - private contractName(target: string): string | null { - if ((ROUTERS[this.network]?.[target] ?? null) === null) { - return null; - } - return `${ROUTERS[this.network][target]} router`; - } - - private formatCalldata( - calldata: BytesLike, - { tokenRegistry, settlementContractAddress }: DecodingTools = {}, - ): DecodedInteractionCall | null { - const selector = utils.hexlify(utils.arrayify(calldata).slice(0, 4)); - - let functionName: string; - let args: Map | null = null; - switch (selector) { - case uniswapInterface.getSighash("swapTokensForExactTokens"): - functionName = "swapTokensForExactTokens"; - try { - const { amountOut, amountInMax, path, to, deadline } = - uniswapInterface.decodeFunctionData(functionName, calldata); - args = new Map(); - const formatAmount = ( - amount: BigNumberish, - token: string | undefined, - ) => { - if (!token || !tokenRegistry?.[token]) { - return amount.toString(); - } - const { symbol, decimals } = tokenRegistry[token]; - return `${utils.formatUnits(amount, decimals ?? 0)} ${ - symbol ?? `token ${token}` - }`; - }; - args.set("amountOut", formatAmount(amountOut, path[path.length - 1])); - args.set("amountInMax", formatAmount(amountInMax, path[0])); - args.set( - "path", - path - .map((t: string) => { - const symbol = tokenRegistry?.[t]?.symbol ?? null; - return t + (symbol === null ? "" : ` (${symbol})`); - }) - .join(" -> "), - ); - args.set( - "to", - to === settlementContractAddress ? "settlement contract" : to, - ); - let deadlineString = "unlimited"; - // Any large date (> year 275,760) is converted to an unlimited - // deadline since `Date` doesn't handle large timestamps well. - try { - const deadlineMillis = deadline.toNumber() * 1000; - // https://262.ecma-international.org/5.1/#sec-15.9.1.1 - if (deadlineMillis <= 8640000000000000) { - deadlineString = `${new Date( - deadlineMillis, - ).toISOString()} (${deadline.toString()})`; - } - } catch { - // anything larger than 2**52-1 is considered unlimited - } - args.set("deadline", deadlineString); - } catch { - // unable to decode arguments, `args` is null - } - break; - default: - return null; - } - - return { functionName, args }; - } - - public async decode( - interaction: Interaction, - decodingTools: DecodingTools = {}, - ): Promise { - const targetName = this.contractName(interaction.target); - if (targetName === null) { - return null; - } - return { - targetName, - call: this.formatCalldata(interaction.callData, decodingTools), - }; - } -} diff --git a/src/tasks/dump.ts b/src/tasks/dump.ts deleted file mode 100644 index 71a424d1..00000000 --- a/src/tasks/dump.ts +++ /dev/null @@ -1,911 +0,0 @@ -import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; -import chalk from "chalk"; -import { - BigNumber, - BigNumberish, - constants, - Contract, - ContractTransaction, - Signer, - utils, - VoidSigner, - providers, -} from "ethers"; -import { task, types } from "hardhat/config"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { - BUY_ETH_ADDRESS, - domain, - Order, - OrderKind, - SigningScheme, - signOrder, - TypedDataDomain, -} from "../ts"; -import { - Api, - CallError, - Environment, - GetQuoteErrorType, - LIMIT_CONCURRENT_REQUESTS, -} from "../ts/api"; - -import { - getDeployedContract, - isSupportedNetwork, - SupportedNetwork, -} from "./ts/deployment"; -import { IGasEstimator, createGasEstimator } from "./ts/gas"; -import { promiseAllWithRateLimit } from "./ts/rate_limits"; -import { Align, displayTable } from "./ts/table"; -import { - isNativeToken, - nativeToken, - NativeToken, - erc20Token, - Erc20Token, - balanceOf, - transfer, - displayName, - estimateTransferGas, -} from "./ts/tokens"; -import { prompt } from "./ts/tui"; -import { formatTokenValue, ethValue } from "./ts/value"; -import { ignoredTokenMessage } from "./withdraw/messages"; - -export const MAX_LATEST_BLOCK_DELAY_SECONDS = 2 * 60; -export const MAX_ORDER_VALIDITY_SECONDS = 24 * 3600; - -const keccak = utils.id; -export const APP_DATA = keccak("GPv2 dump script"); - -interface DumpInstruction { - token: Erc20Token; - quote: Quote; - balance: BigNumber; - needsAllowance: boolean; -} - -export interface Quote { - sellToken: string; - buyToken: string; - sellAmount: BigNumber; - buyAmount: BigNumber; - feeAmount: BigNumber; -} - -interface DisplayDumpInstruction { - fromSymbol: string; - fromAddress: string; - balance: string; - needsAllowance: "yes" | ""; - receivedAmount: string; - feePercent: string; -} - -interface TransferToReceiver { - token: Erc20Token | NativeToken; - amount: BigNumber; - feePercent: number; -} - -interface DumpInstructions { - instructions: DumpInstruction[]; - toToken: Erc20Token | NativeToken; - // Amount of toToken that will be transfered to the receiver address with a - // standard erc20 transfer. This is only set if there is a receiver and the - // specified toToken is also a token to be dumped. - transferToReceiver?: TransferToReceiver; -} - -interface Receiver { - address: string; - isSameAsUser: boolean; -} - -interface GetTransferToReceiverInput { - toToken: Erc20Token | NativeToken; - inputDumpedTokens: string[]; - user: string; - receiver: Receiver; - maxFeePercent: number; - network: SupportedNetwork; - api: Api; - gasEstimator: IGasEstimator; -} -async function getTransferToReceiver({ - toToken, - inputDumpedTokens, - user, - receiver, - maxFeePercent, - network, - api, - gasEstimator, -}: GetTransferToReceiverInput): Promise { - if ( - receiver.isSameAsUser || - !inputDumpedTokens.includes( - isNativeToken(toToken) ? BUY_ETH_ADDRESS : toToken.address, - ) - ) { - return undefined; - } - - const amount = await balanceOf(toToken, user); - if (amount.isZero()) { - console.log( - `Ignored token ${displayName( - toToken, - )}. No balance for that token is available.`, - ); - return undefined; - } - - const [gasPrice, gas, value] = await Promise.all([ - gasEstimator.gasPriceEstimate(), - estimateTransferGas(toToken, user, receiver.address, amount), - ethValue(toToken, amount, network, api), - ]); - const approxGasCost = Number(gas.mul(gasPrice)); - const approxValue = Number(value.toString()); - const feePercent = (100 * approxGasCost) / approxValue; - if (feePercent > maxFeePercent) { - console.log( - ignoredTokenMessage( - [toToken, amount], - `the transaction fee is too large compared to the balance (${feePercent.toFixed( - 2, - )}%).`, - ), - ); - return undefined; - } - - return { - token: toToken, - amount, - feePercent, - }; -} - -export interface GetDumpInstructionInput { - dumpedTokens: string[]; - toTokenAddress: string | undefined; // undefined defaults to native token (e.g., ETH) - user: string; - vaultRelayer: string; - maxFeePercent: number; - slippageBps: number; - validity: number; - receiver: Receiver; - hre: HardhatRuntimeEnvironment; - network: SupportedNetwork; - api: Api; - gasEstimator: IGasEstimator; -} -/** - * This function recovers all information needed to dump the input list of - * tokens into toToken with GPv2. It returns structured information on all - * operations to execute, sorted so that the last operation is the one - * recovering the largest amount of toToken. - * - * Information on operations to perform include that needed for creating orders, - * setting allowances and, if needed, transfering tokens. - * - * Note that this function is not supposed to execute any state-changing - * operation (either onchain or in the backend). - * - * Care is taken in handling the following logic: - * - handling the case where the receiver is not the signer address - * - handling ETH buy addresses (especially with a custom receiver) - * - rejecting dump requests for tokens for which the fee to pay is larger than - * a given threshold - */ -export async function getDumpInstructions({ - dumpedTokens: inputDumpedTokens, - toTokenAddress, - user, - vaultRelayer: vaultRelayer, - maxFeePercent, - slippageBps, - validity, - receiver, - hre, - network, - api, - gasEstimator, -}: GetDumpInstructionInput): Promise { - // todo: support dumping ETH by wrapping them - if (inputDumpedTokens.includes(BUY_ETH_ADDRESS)) { - throw new Error( - `Dumping the native token is not supported. Remove the ETH flag address ${BUY_ETH_ADDRESS} from the list of tokens to dump.`, - ); - } - - let toToken: Erc20Token | NativeToken; - if (toTokenAddress === undefined || toTokenAddress === BUY_ETH_ADDRESS) { - toToken = nativeToken(hre); - } else { - const erc20 = await erc20Token(toTokenAddress, hre); - if (erc20 === null) { - throw new Error( - `Input toToken at address ${toTokenAddress} is not a valid Erc20 token.`, - ); - } - toToken = erc20; - } - - const transferToReceiver = getTransferToReceiver({ - toToken, - inputDumpedTokens, - user, - receiver, - maxFeePercent, - network, - api, - gasEstimator, - }); - - const dumpedTokens = Array.from(new Set(inputDumpedTokens)).filter( - (token) => token !== (toTokenAddress ?? BUY_ETH_ADDRESS), - ); - - const computedInstructions: (DumpInstruction | null)[] = ( - await promiseAllWithRateLimit( - dumpedTokens.map((tokenAddress) => async ({ consoleLog }) => { - const token = await erc20Token(tokenAddress, hre); - if (token === null) { - consoleLog( - `Dump request skipped for invalid ERC-20 token at address ${tokenAddress}.`, - ); - return null; - } - const [balance, approvedAmount]: [BigNumber, BigNumber] = - await Promise.all([ - token.contract.balanceOf(user).then(BigNumber.from), - token.contract.allowance(user, vaultRelayer).then(BigNumber.from), - ]); - const needsAllowance = approvedAmount.lt(balance); - if (balance.isZero()) { - consoleLog( - `Ignored token ${displayName( - token, - )}. No balance for that token is available.`, - ); - return null; - } - const quote = await getQuote({ - sellToken: token, - buyToken: toToken, - api, - sellAmountBeforeFee: balance, - maxFeePercent, - slippageBps, - validTo: computeValidTo(validity), - user, - }); - if (quote === null) { - return null; - } - - return { - token, - balance, - quote, - needsAllowance, - }; - }), - { rateLimit: LIMIT_CONCURRENT_REQUESTS }, - ) - ).filter((inst) => inst !== null); - // note: null entries have already been filtered out - const instructions = computedInstructions as DumpInstruction[]; - instructions.sort((lhs, rhs) => - lhs.quote.buyAmount.eq(rhs.quote.buyAmount) - ? 0 - : lhs.quote.buyAmount.lt(rhs.quote.buyAmount) - ? -1 - : 1, - ); - - return { - instructions, - toToken, - transferToReceiver: await transferToReceiver, - }; -} -interface QuoteInput { - sellToken: Erc20Token; - buyToken: Erc20Token | NativeToken; - sellAmountBeforeFee: BigNumber; - validTo: number; - maxFeePercent: number; - slippageBps: number; - user: string; - api: Api; -} - -// Returns null if the fee is not satisfying balance or maxFeePercent. May throw if an unexpected error occurs -export async function getQuote({ - sellToken, - buyToken, - sellAmountBeforeFee, - validTo, - maxFeePercent, - slippageBps, - user, - api, -}: QuoteInput): Promise { - let quote; - const buyTokenAddress = isNativeToken(buyToken) - ? BUY_ETH_ADDRESS - : buyToken.address; - try { - const quotedOrder = await api.getQuote({ - sellToken: sellToken.address, - buyToken: buyTokenAddress, - validTo, - appData: APP_DATA, - partiallyFillable: false, - from: user, - kind: OrderKind.SELL, - sellAmountBeforeFee, - }); - quote = { - sellToken: sellToken.address, - buyToken: buyTokenAddress, - sellAmount: BigNumber.from(quotedOrder.quote.sellAmount), - buyAmount: buyAmountWithSlippage( - quotedOrder.quote.buyAmount, - slippageBps, - ), - feeAmount: BigNumber.from(quotedOrder.quote.feeAmount), - }; - } catch (e) { - if ( - (e as CallError)?.apiError?.errorType === - GetQuoteErrorType.SellAmountDoesNotCoverFee - ) { - console.log( - ignoredTokenMessage( - [sellToken, sellAmountBeforeFee], - "the trading fee is larger than the dumped amount.", - ), - ); - return null; - } else if ( - (e as CallError)?.apiError?.errorType === GetQuoteErrorType.NoLiquidity - ) { - console.log( - ignoredTokenMessage( - [sellToken, sellAmountBeforeFee], - "not enough liquidity to dump tokens.", - ), - ); - return null; - } else { - throw e; - } - } - const approxAmount = Number(sellAmountBeforeFee.toString()); - const approxFee = Number(quote.feeAmount.toString()); - const feePercent = (100 * approxFee) / approxAmount; - if (feePercent > maxFeePercent) { - console.log( - ignoredTokenMessage( - [sellToken, sellAmountBeforeFee], - `the trading fee is too large compared to the balance (${feePercent.toFixed( - 2, - )}%).`, - ), - ); - return null; - } - return quote; -} - -function buyAmountWithSlippage( - buyAmountWithoutSlippage: BigNumberish, - slippageBps: number, -): BigNumber { - // reduce buy amount by slippage - return BigNumber.from(buyAmountWithoutSlippage) - .mul(10000 - slippageBps) - .div(10000); -} - -export function computeValidTo(validity: number) { - const now = Math.floor(Date.now() / 1000); - return now + validity; -} - -function formatInstruction( - { - token: fromToken, - quote, - balance, - needsAllowance: inputNeedsAllowance, - }: DumpInstruction, - toToken: Erc20Token | NativeToken, -): DisplayDumpInstruction { - const fromSymbol = fromToken.symbol ?? "! unknown symbol !"; - const fromAddress = fromToken.address; - const fromDecimals = fromToken.decimals ?? 0; - const needsAllowance = inputNeedsAllowance ? "yes" : ""; - const feePercent = quote.feeAmount.mul(10000).div(balance).lt(1) - ? "<0.01" - : utils.formatUnits(quote.feeAmount.mul(10000).div(balance), 2); - return { - fromSymbol, - fromAddress, - needsAllowance, - balance: formatTokenValue(balance, fromDecimals, 18), - receivedAmount: formatTokenValue( - quote.buyAmount, - toToken.decimals ?? 0, - 18, - ), - feePercent, - }; -} - -function displayOperations( - instructions: DumpInstruction[], - toToken: Erc20Token | NativeToken, -) { - const formattedInstructions = instructions.map((inst) => - formatInstruction(inst, toToken), - ); - const orderWithoutAllowances = [ - "fromAddress", - "fromSymbol", - "receivedAmount", - "balance", - "feePercent", - ] as const; - const order = instructions.some(({ needsAllowance }) => needsAllowance) - ? ([ - ...orderWithoutAllowances.slice(0, 2), - "needsAllowance", - ...orderWithoutAllowances.slice(2), - ] as const) - : orderWithoutAllowances; - const header = { - fromAddress: "token address", - fromSymbol: "symbol", - balance: "dumped amount", - feePercent: "fee %", - receivedAmount: `received amount${ - toToken.symbol ? ` (${toToken.symbol})` : "" - }`, - needsAllowance: "needs allowance?", - }; - console.log(chalk.bold("List of dumped tokens:")); - displayTable(header, formattedInstructions, order, { - balance: { align: Align.Right, maxWidth: 30 }, - receivedAmount: { align: Align.Right, maxWidth: 30 }, - fromSymbol: { maxWidth: 20 }, - }); - console.log(); -} - -interface CreateAllowancesOptions { - gasEstimator: IGasEstimator; - requiredConfirmations?: number | undefined; -} -async function createAllowances( - allowances: Erc20Token[], - signer: Signer, - vaultRelayer: string, - { gasEstimator, requiredConfirmations }: CreateAllowancesOptions, -) { - let lastTransaction: ContractTransaction | undefined = undefined; - let current_nonce = await signer.getTransactionCount(); - for (const token of allowances) { - console.log( - `Approving vault relayer to trade token ${displayName(token)}...`, - ); - const fee = await gasEstimator.txGasPrice(); - lastTransaction = (await token.contract - .connect(signer) - .approve(vaultRelayer, constants.MaxUint256, { - nonce: current_nonce, - ...fee, - })) as ContractTransaction; - current_nonce++; - } - if (lastTransaction !== undefined) { - // note: the last approval is (excluded reorgs) the last that is included - // in a block, so awaiting it for confirmations means that also all others - // have at least this number of confirmations. - await lastTransaction.wait(requiredConfirmations); - } -} - -async function createOrders( - instructions: DumpInstruction[], - toToken: Erc20Token | NativeToken, - signer: SignerWithAddress, - receiver: Receiver, - domainSeparator: TypedDataDomain, - validity: number, - maxFeePercent: number, - slippageBps: number, - api: Api, -) { - for (const inst of instructions) { - const sellToken = inst.token.address; - const buyToken = isNativeToken(toToken) ? BUY_ETH_ADDRESS : toToken.address; - try { - // Re-quote for up-to-date fee (in case approval took long) - const updatedQuote = await getQuote({ - sellToken: inst.token, - buyToken: toToken, - sellAmountBeforeFee: inst.balance, - validTo: computeValidTo(validity), - user: signer.address, - api, - maxFeePercent, - slippageBps, - }); - if (updatedQuote !== null) { - inst.quote = updatedQuote; - } - } catch (error) { - console.log(error, "Couldn't re-quote fee, hoping old fee is still good"); - } - - const order: Order = { - sellToken, - buyToken, - sellAmount: inst.quote.sellAmount, - buyAmount: inst.quote.buyAmount, - feeAmount: inst.quote.feeAmount, - kind: OrderKind.SELL, - appData: APP_DATA, - // todo: switch to true when partially fillable orders will be - // supported by the services - partiallyFillable: false, - validTo: computeValidTo(validity), - receiver: receiver.isSameAsUser ? undefined : receiver.address, - }; - const signature = await signOrder( - domainSeparator, - order, - signer, - SigningScheme.EIP712, - ); - - console.log( - `Creating order selling ${inst.token.symbol ?? inst.token.address}...`, - ); - try { - const orderUid = await api.placeOrder({ - order, - signature, - }); - console.log(`Successfully created order with uid ${orderUid}`); - } catch (error) { - if ( - error instanceof Error && - (error as CallError)?.apiError !== undefined - ) { - // not null because of the condition in the if statement above - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { errorType, description } = (error as CallError).apiError!; - console.error( - `Failed submitting order selling ${ - inst.token.symbol ?? inst.token.address - }, the server returns ${errorType} (${description})`, - ); - console.error(`Order details: ${JSON.stringify(order)}`); - } else { - throw error; - } - } - } -} -async function transferSameTokenToReceiver( - transferToReceiver: TransferToReceiver, - signer: Signer, - receiver: Receiver, -) { - console.log( - `Transfering token ${transferToReceiver.token.symbol} to receiver...`, - ); - const receipt = await transfer( - transferToReceiver.token, - signer, - receiver.address, - transferToReceiver.amount, - ); - await receipt.wait(); -} - -export function assertNotBuyingNativeAsset(toToken: string | undefined) { - // This function checks that toToken is not the native asset (e.g., ETH). - // Technically, this script was built to support selling ETH. However, there - // are two requirement from the backend for it to work: - // 1. Sending native assets to smart contracts should be supported. At the - // time of writing, this returns an error when creating the order. - // 2. Selling wrapped native assets for their unwrapped counterpart should be - // supported. It currently returns an error about the fact that the token - // is the same. - if ([undefined, BUY_ETH_ADDRESS].includes(toToken)) { - throw new Error("Receiving native asset is not supported yet."); - } -} - -interface DumpInput { - validity: number; - maxFeePercent: number; - slippageBps: number; - dumpedTokens: string[]; - toToken: string; - settlement: Contract; - signer: SignerWithAddress; - receiver: string | undefined; - network: SupportedNetwork; - hre: HardhatRuntimeEnvironment; - api: Api; - dryRun: boolean; - gasEstimator: IGasEstimator; - doNotPrompt?: boolean | undefined; - confirmationsAfterApproval?: number | undefined; -} -export async function dump({ - validity, - maxFeePercent, - slippageBps, - dumpedTokens, - toToken: toTokenAddress, - settlement, - signer, - receiver: inputReceiver, - network, - hre, - api, - dryRun, - gasEstimator, - doNotPrompt, - confirmationsAfterApproval, -}: DumpInput): Promise { - const { ethers } = hre; - - // TODO: remove once native asset orders are fully supported. - assertNotBuyingNativeAsset(toTokenAddress); - - const [chainId, vaultRelayer] = await Promise.all([ - ethers.provider.getNetwork().then((n) => n.chainId), - (await settlement.vaultRelayer()) as string, - ]); - const domainSeparator = domain(chainId, settlement.address); - const receiverAddress = inputReceiver ?? signer.address; - const receiver: Receiver = { - address: receiverAddress, - isSameAsUser: receiverAddress === signer.address, - }; - - const { instructions, toToken, transferToReceiver } = - await getDumpInstructions({ - dumpedTokens, - toTokenAddress, - user: signer.address, - vaultRelayer: vaultRelayer, - maxFeePercent, - slippageBps, - validity, - receiver, - hre, - network, - api, - gasEstimator, - }); - const willTrade = instructions.length !== 0; - const willTransfer = transferToReceiver !== undefined; - if (willTrade) { - displayOperations(instructions, toToken); - } - - let sumReceived = instructions.reduce( - (sum, inst) => sum.add(inst.quote.buyAmount), - constants.Zero, - ); - const needAllowances = instructions - .filter(({ needsAllowance }) => needsAllowance) - .map(({ token }) => token); - if (needAllowances.length !== 0) { - console.log( - `Before creating the orders, a total of ${needAllowances.length} allowances will be granted to the vault relayer.`, - ); - } - const toTokenName = isNativeToken(toToken) - ? toToken.symbol - : toToken.symbol ?? `units of token ${toToken.address}`; - if (willTransfer) { - const { amount, token, feePercent } = transferToReceiver; - console.log( - `${ - willTrade ? "Moreover, a" : "A" - } token transfer for ${utils.formatUnits( - amount, - token.decimals ?? 0, - )} ${toTokenName} to the receiver address ${ - receiver.address - } will be submitted onchain. The transfer network fee corresponds to about ${ - feePercent < 0.01 ? "< 0.01" : feePercent.toFixed(2) - }% of the withdrawn amount.`, - ); - sumReceived = sumReceived.add(amount); - } - if (willTrade || willTransfer) { - console.log( - `${ - receiver.isSameAsUser - ? `Your address (${receiver.address})` - : `The receiver address ${receiver.address}` - } will receive at least ${utils.formatUnits( - sumReceived, - toToken.decimals ?? 0, - )} ${toTokenName} from the tokens listed above.`, - ); - } else { - console.log("Nothing to do."); - return; - } - if (!dryRun && (doNotPrompt || (await prompt(hre, "Submit?")))) { - await createAllowances(needAllowances, signer, vaultRelayer, { - gasEstimator, - // If the services don't register the allowance before the order, - // then creating a new order with the API returns an error. - // Moreover, there is no distinction in the error between a missing - // allowance and a failed order creation, which could occur for - // valid reasons. - requiredConfirmations: confirmationsAfterApproval, - }); - - await createOrders( - instructions, - toToken, - signer, - receiver, - domainSeparator, - validity, - maxFeePercent, - slippageBps, - api, - ); - - if (willTransfer) { - await transferSameTokenToReceiver(transferToReceiver, signer, receiver); - } - - console.log( - `Done! The orders will expire in the next ${validity / 60} minutes.`, - ); - } -} - -const setupDumpTask: () => void = () => - task("dump") - .addOptionalParam( - "origin", - "Address from which to dump. If not specified, it defaults to the first provided account", - ) - .addOptionalParam( - "toToken", - "All input tokens will be dumped to this token. If not specified, it defaults to the network's native token (e.g., ETH)", - ) - .addOptionalParam( - "receiver", - "The address that will receive the funds obtained from dumping the token. Defaults to the origin address", - ) - .addOptionalParam( - "validity", - `How long the sell orders will be valid after their creation in seconds. It cannot be larger than ${MAX_ORDER_VALIDITY_SECONDS}`, - 20 * 60, - types.int, - ) - .addOptionalParam( - "maxFeePercent", - "If, for any token, the amount of fee to be paid is larger than this percent of the traded amount, that token is not traded", - 5, - types.float, - ) - .addOptionalParam( - "slippageBps", - "The slippage in basis points for selling the dumped tokens", - 10, - types.int, - ) - .addOptionalParam( - "apiUrl", - "If set, the script contacts the API using the given url. Otherwise, the default prod url for the current network is used", - ) - .addFlag( - "dryRun", - "Just simulate the result instead of executing the transaction on the blockchain.", - ) - .addFlag( - "blocknativeGasPrice", - "Use BlockNative gas price estimates for transactions.", - ) - .addVariadicPositionalParam( - "dumpedTokens", - "List of tokens that will be dumped in exchange for toToken. Multiple tokens are separated by spaces", - ) - .setAction( - async ( - { - origin, - toToken, - dumpedTokens, - maxFeePercent, - slippageBps, - dryRun, - receiver, - validity, - apiUrl, - blocknativeGasPrice, - }, - hre, - ) => { - const network = hre.network.name; - if (!isSupportedNetwork(network)) { - throw new Error(`Unsupported network ${hre.network.name}`); - } - const gasEstimator = createGasEstimator(hre, { - blockNative: blocknativeGasPrice, - }); - const api = new Api(network, apiUrl ?? Environment.Prod); - const [signers, settlement] = await Promise.all([ - hre.ethers.getSigners(), - getDeployedContract("GPv2Settlement", hre), - ]); - let signer = - origin === undefined - ? signers[0] - : signers.find((signer) => signer.address === origin); - if (signer === undefined) { - if (!dryRun) { - throw new Error( - `No signer found${ - origin === undefined ? "" : ` for address ${origin}` - }. Did you export a valid private key?`, - ); - } else { - // The next lines are a bit hacky since a VoidSigner is not a JsonRpcSigner, but it's still good enough as - // it returns an error every time a signing request is made (which shouldn't happen in dry run). The only - // method missing from a JsonRpcSigner is sendTransaction, which would fail with an obscure error if called. - signer = await SignerWithAddress.create( - new VoidSigner(origin) as unknown as providers.JsonRpcSigner, - ); - } - } - console.log(`Using account ${signer.address}`); - - if (validity > MAX_ORDER_VALIDITY_SECONDS) { - throw new Error("Order validity too large"); - } - - await dump({ - validity, - maxFeePercent, - slippageBps, - dumpedTokens, - toToken, - settlement, - signer, - receiver, - network, - hre, - api, - dryRun, - gasEstimator, - confirmationsAfterApproval: 2, - }); - }, - ); - -export { setupDumpTask }; diff --git a/src/tasks/index.ts b/src/tasks/index.ts index b98b718c..b97bc255 100644 --- a/src/tasks/index.ts +++ b/src/tasks/index.ts @@ -1,25 +1,5 @@ -import { setupCopyArtifactsTask } from "./artifacts"; -import { setupDecodeTask } from "./decode"; -import { setupDumpTask } from "./dump"; -import { setupPlaceOrderTask } from "./placeOrder"; -import { setupSelfSellTask } from "./selfSell"; -import { setupSetApprovalsTask } from "./setApprovals"; -import { setupSolversTask } from "./solvers"; -import { setupTenderlyTask } from "./tenderly"; import { setupTransferOwnershipTask } from "./transferOwnership"; -import { setupWithdrawTask } from "./withdraw"; -import { setupWithdrawServiceTask } from "./withdrawService"; export function setupTasks(): void { - setupCopyArtifactsTask(); - setupDecodeTask(); - setupDumpTask(); - setupPlaceOrderTask(); - setupSelfSellTask(); - setupSetApprovalsTask(); - setupSolversTask(); - setupTenderlyTask(); setupTransferOwnershipTask(); - setupWithdrawTask(); - setupWithdrawServiceTask(); } diff --git a/src/tasks/placeOrder.ts b/src/tasks/placeOrder.ts deleted file mode 100644 index 43cabb67..00000000 --- a/src/tasks/placeOrder.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { BigNumberish } from "@ethersproject/bignumber"; -import { utils } from "ethers"; -import { task } from "hardhat/config"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { domain, OrderKind, SigningScheme, signOrder } from "../ts"; -import { - Api, - Environment, - SellAmountAfterFee, - SellAmountBeforeFee, - BuyAmountAfterFee, -} from "../ts/api"; - -import { getDeployedContract } from "./ts/deployment"; -import { prompt } from "./ts/tui"; - -type OrderType = "sellBeforeFee" | "sellAfterFee" | "buyAfterFee"; - -const keccak = utils.id; -const APP_DATA = keccak("GPv2 place order script"); -const ORDER_VALIDITY = 60 * 30; - -interface Args { - orderType: OrderType; - from: string; - to: string; - amountAtoms: BigNumberish; - apiUrl: string | null; -} - -async function placeOrder( - { orderType, from, to, amountAtoms, apiUrl }: Args, - hre: HardhatRuntimeEnvironment, -) { - let amount: SellAmountBeforeFee | SellAmountAfterFee | BuyAmountAfterFee; - switch (orderType) { - case "sellBeforeFee": - amount = { - kind: OrderKind.SELL, - sellAmountBeforeFee: amountAtoms, - }; - break; - case "sellAfterFee": - amount = { - kind: OrderKind.SELL, - sellAmountAfterFee: amountAtoms, - }; - break; - case "buyAfterFee": - amount = { - kind: OrderKind.BUY, - buyAmountAfterFee: amountAtoms, - }; - break; - default: - throw new Error(`Unhandled order type ${orderType}`); - } - - const [[signer], settlement, chainId] = await Promise.all([ - hre.ethers.getSigners(), - getDeployedContract("GPv2Settlement", hre), - hre.getChainId(), - ]); - - const api = new Api(hre.network.name, apiUrl || Environment.Prod); - const quote = await api.getQuote({ - sellToken: from, - buyToken: to, - validTo: Math.floor(Date.now() / 1000) + ORDER_VALIDITY, - appData: APP_DATA, - partiallyFillable: false, - from: signer.address, - ...amount, - }); - - console.log("Received quote:", quote); - - if (await prompt(hre, "Would you like to place this order?")) { - const domainSeparator = domain(parseInt(chainId), settlement.address); - const signature = await signOrder( - domainSeparator, - quote.quote, - signer, - SigningScheme.EIP712, - ); - - const uid = await api.placeOrder({ - order: quote.quote, - signature, - }); - console.log(`Placed order with uid ${uid}`); - } -} - -const setupPlaceOrderTask: () => void = () => { - task( - "place-order", - "Places a limit order on GPv2 according to the price estimation endpoint with 30 minute validity and 0 slippage", - ) - .addPositionalParam( - "orderType", - `They type of order you are placing. *sellBeforeFee* will deduct the fee from the net sell amount (leading to that exact amount leaving your wallet). *sellAfterFee* will lead to the amount + fee leaving your wallet, *buyAfterFee* leads to buying the exact amount`, - ) - .addParam("from", "Address of the token you are selling") - .addParam("to", "Address of the token you are buying") - .addParam( - "amountAtoms", - "Amount of token you are willing to buy/sell (depending on order type)", - ) - .addOptionalParam( - "apiUrl", - "If set, the script contacts the API using the given url. Otherwise, the default prod url for the current network is used", - ) - .setAction(placeOrder); -}; - -export { setupPlaceOrderTask }; diff --git a/src/tasks/selfSell.ts b/src/tasks/selfSell.ts deleted file mode 100644 index 4640aa51..00000000 --- a/src/tasks/selfSell.ts +++ /dev/null @@ -1,1014 +0,0 @@ -import "@nomiclabs/hardhat-ethers"; - -import chalk from "chalk"; -import { BigNumber, constants, Contract, TypedDataDomain, utils } from "ethers"; -import { task, types } from "hardhat/config"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { - BUY_ETH_ADDRESS, - computeOrderUid, - domain, - EncodedSettlement, - Order, - OrderKind, - PreSignSignature, - SettlementEncoder, - SigningScheme, -} from "../ts"; -import { - Api, - CallError, - Environment, - LIMIT_CONCURRENT_REQUESTS, -} from "../ts/api"; - -import { - APP_DATA, - assertNotBuyingNativeAsset, - computeValidTo, - getQuote, - MAX_ORDER_VALIDITY_SECONDS, - Quote, -} from "./dump"; -import { - getDeployedContract, - isSupportedNetwork, - SupportedNetwork, -} from "./ts/deployment"; -import { createGasEstimator, IGasEstimator } from "./ts/gas"; -import { - DisappearingLogFunctions, - promiseAllWithRateLimit, -} from "./ts/rate_limits"; -import { getSolvers } from "./ts/solver"; -import { Align, displayTable } from "./ts/table"; -import { Erc20Token, erc20Token } from "./ts/tokens"; -import { prompt } from "./ts/tui"; -import { - formatGasCost, - formatTokenValue, - formatUsdValue, - REFERENCE_TOKEN, - ReferenceToken, - usdValue, - usdValueOfEth, -} from "./ts/value"; -import { BalanceOutput, getAmounts } from "./withdraw"; -import { ignoredTokenMessage } from "./withdraw/messages"; -import { proposeTransaction } from "./withdraw/safe"; -import { submitSettlement } from "./withdraw/settle"; -import { getSignerOrAddress, SignerOrAddress } from "./withdraw/signer"; -import { sendSlackMessage } from "./withdraw/slack"; -import { getTokensWithBalanceAbove } from "./withdraw/token_balances"; -import { getAllTradedTokens } from "./withdraw/traded_tokens"; - -interface DisplayOrder { - symbol: string; - balance: string; - sellAmount: string; - sellAmountUsd: string; - address: string; - buyAmount: string; - feePercent: string; - needsAllowance: "yes" | ""; -} - -interface ComputeSettlementInput { - orders: Pick[]; - solverForSimulation: string; - settlement: Contract; - vaultRelayer: string; - hre: HardhatRuntimeEnvironment; -} -async function computeSettlement({ - orders, - solverForSimulation, - settlement, - vaultRelayer, - hre, -}: ComputeSettlementInput) { - const encoder = new SettlementEncoder({}); - for (const order of orders) { - if (order.needsAllowance) { - encoder.encodeInteraction({ - target: order.sellToken.address, - callData: order.sellToken.contract.interface.encodeFunctionData( - "approve", - [vaultRelayer, constants.MaxUint256], - ), - }); - } - encoder.encodeInteraction({ - target: settlement.address, - callData: settlement.interface.encodeFunctionData("setPreSignature", [ - order.orderUid, - true, - ]), - }); - } - - const finalSettlement = encoder.encodedSettlement({}); - const gas = await settlement - .connect(hre.ethers.provider) - .estimateGas.settle(...finalSettlement, { - from: solverForSimulation, - }); - return { - finalSettlement, - gas, - }; -} - -interface ComputeSettlementWithPriceInput - extends Omit { - orders: OrderDetails[]; - gasPrice: BigNumber; - network: SupportedNetwork; - usdReference: ReferenceToken; - api: Api; -} -async function computeSettlementWithPrice({ - orders, - solverForSimulation, - settlement, - vaultRelayer, - gasPrice, - network, - usdReference, - api, - hre, -}: ComputeSettlementWithPriceInput) { - const { gas, finalSettlement } = await computeSettlement({ - orders, - solverForSimulation, - settlement, - vaultRelayer, - hre, - }); - - const transactionEthCost = gas.mul(gasPrice); - // The following ternary operator is used as a hack to avoid having to - // set expectations for the gas value in the tests, since gas values - // could easily change with any minor changes to the tests - const transactionUsdCost = - hre.network.name === "hardhat" - ? constants.Zero - : await usdValueOfEth(transactionEthCost, usdReference, network, api); - const soldValue = orders.reduce( - (sum, { sellAmountUsd }) => sum.add(sellAmountUsd), - constants.Zero, - ); - - return { - finalSettlement, - transactionEthCost, - transactionUsdCost, - gas, - soldValue, - }; -} - -interface ComputeOrderAfterFeeSlippageInput { - quote: Quote; - amounts: BalanceOutput; - feeSlippageBps: number; - receiver: string; - usdReference: ReferenceToken; - sellToken: Erc20Token; - validTo: number; -} -function computeOrderAfterFeeSlippage({ - quote, - amounts, - feeSlippageBps, - receiver, - usdReference, - sellToken, - validTo, -}: ComputeOrderAfterFeeSlippageInput): { - order: Order; - adjustedFee: BigNumber; -} | null { - // Tentatively adjust the fee linearly assuming that the order's exchange rate - // doesn't depend on the amount. - - // Fee amounts are taken from the sell token. We need to reserve the following - // extra amount because it could be taken by a gas price increase: - const reservedSellAmountForFeeSlippage = quote.feeAmount - .mul(feeSlippageBps) - .div(10000); - - const fullSellAmount = quote.sellAmount.add(quote.feeAmount); - // We take this extra fee from the buy amount, since the full sell amount is - // fixed. - const reservedBuyAmountForFeeSlippage = reservedSellAmountForFeeSlippage - .mul(quote.buyAmount) - .div(fullSellAmount); - - if (reservedBuyAmountForFeeSlippage.gte(quote.buyAmount)) { - console.log( - ignoredTokenMessage( - [sellToken, amounts.balance], - `adjusting order for fee slippage requires more than what is obtained from selling`, - [usdReference, amounts.balanceUsd], - ), - ); - return null; - } - - return { - order: { - sellToken: quote.sellToken, - buyToken: quote.buyToken, - receiver, - sellAmount: fullSellAmount, - buyAmount: quote.buyAmount.sub(reservedBuyAmountForFeeSlippage), - validTo, - appData: APP_DATA, - feeAmount: constants.Zero, - kind: OrderKind.SELL, - partiallyFillable: true, - }, - adjustedFee: quote.feeAmount.add(reservedSellAmountForFeeSlippage), - }; -} - -interface OrderDetails { - order: Order; - sellAmountUsd: BigNumber; - feeUsd: BigNumber; - balance: BigNumber; - balanceUsd: BigNumber; - sellToken: Erc20Token; - orderUid: string; - needsAllowance: boolean; - extraGas: BigNumber; -} - -interface GetOrdersInput { - tokens: string[]; - toToken: Erc20Token; - settlement: Contract; - vaultRelayer: string; - minValue: string; - leftover: string; - maxFeePercent: number; - priceSlippageBps: number; - feeSlippageBps: number; - validity: number; - hre: HardhatRuntimeEnvironment; - usdReference: ReferenceToken; - receiver: string; - api: Api; - domainSeparator: TypedDataDomain; - solverForSimulation: string; -} -async function getOrders({ - tokens, - toToken, - settlement, - vaultRelayer, - minValue, - leftover, - maxFeePercent, - priceSlippageBps, - feeSlippageBps, - validity, - hre, - usdReference, - receiver, - api, - domainSeparator, - solverForSimulation, -}: GetOrdersInput): Promise { - const minValueWei = utils.parseUnits(minValue, usdReference.decimals); - const leftoverWei = utils.parseUnits(leftover, usdReference.decimals); - const gasEmptySettlement = computeSettlement({ - orders: [], - solverForSimulation, - settlement, - vaultRelayer, - hre, - }).then(({ gas }) => gas); - - const computeOrderInstructions = tokens.map( - (tokenAddress) => - async ({ consoleLog }: DisappearingLogFunctions) => { - const sellToken = await erc20Token(tokenAddress, hre); - if (sellToken === null) { - throw new Error( - `There is no valid ERC20 token at address ${tokenAddress}`, - ); - } - - const amounts = await getAmounts({ - token: sellToken, - usdReference, - settlement, - api, - leftoverWei, - minValueWei, - consoleLog, - }); - if (amounts === null) { - return null; - } - - let allowance; - try { - allowance = BigNumber.from( - await sellToken.contract.allowance( - settlement.address, - vaultRelayer, - ), - ); - } catch (e) { - consoleLog( - ignoredTokenMessage( - [sellToken, amounts.balance], - "cannot determine size of vault relayer allowance", - ), - ); - return null; - } - const needsAllowance = allowance.lt(amounts.netAmount); - - const validTo = computeValidTo(validity); - const owner = settlement.address; - const quote = await getQuote({ - sellToken, - buyToken: toToken, - api, - sellAmountBeforeFee: amounts.netAmount, - maxFeePercent, - slippageBps: priceSlippageBps, - validTo, - user: owner, - }); - if (quote === null) { - return null; - } - const adjustedOrder = computeOrderAfterFeeSlippage({ - quote, - amounts, - feeSlippageBps, - receiver, - usdReference, - sellToken, - validTo, - }); - if (adjustedOrder === null) { - return null; - } - const { order, adjustedFee } = adjustedOrder; - const orderUid = computeOrderUid(domainSeparator, order, owner); - - let feeUsd; - try { - feeUsd = await usdValue( - order.sellToken, - adjustedFee, - usdReference, - api, - ); - } catch (error) { - if (!(error instanceof Error)) { - throw error; - } - consoleLog( - ignoredTokenMessage( - [sellToken, amounts.balance], - `cannot determine USD value of fee (${error.message})`, - [usdReference, amounts.balanceUsd], - ), - ); - return null; - } - - const extraGas = ( - await computeSettlement({ - orders: [ - { - needsAllowance, - orderUid, - sellToken, - }, - ], - solverForSimulation, - settlement, - vaultRelayer, - hre, - }) - ).gas.sub(await gasEmptySettlement); - - return { - order, - feeUsd, - sellAmountUsd: amounts.netAmountUsd, - balance: amounts.balance, - balanceUsd: amounts.balanceUsd, - sellToken, - owner: settlement.address, - orderUid, - needsAllowance, - extraGas, - }; - }, - ); - const processedOrders: (OrderDetails | null)[] = - await promiseAllWithRateLimit(computeOrderInstructions, { - message: "retrieving available tokens", - rateLimit: LIMIT_CONCURRENT_REQUESTS, - }); - return processedOrders.filter((order) => order !== null) as OrderDetails[]; -} - -function formatOrder( - order: OrderDetails, - toToken: Erc20Token, - usdReference: ReferenceToken, -): DisplayOrder { - const formatSellTokenDecimals = order.sellToken.decimals ?? 18; - const formatBuyTokenDecimals = toToken.decimals ?? 18; - const feePercentBps = order.feeUsd.mul(10000).div(order.sellAmountUsd); - const feePercent = feePercentBps.lt(1) - ? "<0.01" - : utils.formatUnits(feePercentBps, 2); - return { - address: order.sellToken.address, - sellAmountUsd: formatUsdValue(order.balanceUsd, usdReference), - balance: formatTokenValue(order.balance, formatSellTokenDecimals, 10), - sellAmount: formatTokenValue( - BigNumber.from(order.order.sellAmount), - formatSellTokenDecimals, - 10, - ), - symbol: order.sellToken.symbol ?? "unknown token", - buyAmount: formatTokenValue( - BigNumber.from(order.order.buyAmount), - formatBuyTokenDecimals, - 10, - ), - feePercent, - needsAllowance: order.needsAllowance ? "yes" : "", - }; -} - -function displayOrders( - orders: OrderDetails[], - usdReference: ReferenceToken, - toToken: Erc20Token, -) { - const formattedOrders = orders.map((o) => - formatOrder(o, toToken, usdReference), - ); - const orderWithoutAllowances = [ - "address", - "sellAmountUsd", - "balance", - "sellAmount", - "symbol", - "buyAmount", - "feePercent", - ] as const; - const order = orders.some((o) => o.needsAllowance) - ? ([...orderWithoutAllowances, "needsAllowance"] as const) - : orderWithoutAllowances; - const header = { - address: "address", - sellAmountUsd: "value (USD)", - balance: "balance", - sellAmount: "sold amount", - symbol: "symbol", - buyAmount: `buy amount${toToken.symbol ? ` (${toToken.symbol})` : ""}`, - feePercent: "fee %", - needsAllowance: "needs allowance?", - }; - console.log(chalk.bold("Amounts to sell:")); - displayTable(header, formattedOrders, order, { - sellAmountUsd: { align: Align.Right }, - balance: { align: Align.Right, maxWidth: 30 }, - sellAmount: { align: Align.Right, maxWidth: 30 }, - symbol: { maxWidth: 20 }, - buyAmount: { align: Align.Right, maxWidth: 30 }, - }); - console.log(); -} - -interface SelfSellInput { - solver: SignerOrAddress; - tokens: string[] | undefined; - toToken: string; - minValue: string; - leftover: string; - maxFeePercent: number; - priceSlippageBps: number; - feeSlippageBps: number; - validity: number; - receiver: string; - authenticator: Contract; - settlement: Contract; - settlementDeploymentBlock: number; - network: SupportedNetwork; - usdReference: ReferenceToken; - hre: HardhatRuntimeEnvironment; - api: Api; - dryRun: boolean; - gasEstimator: IGasEstimator; - doNotPrompt?: boolean | undefined; - requiredConfirmations?: number | undefined; - domainSeparator: TypedDataDomain; - solverIsSafe: boolean; - notifySlackChannel?: string; - maxOrders: number; -} - -async function prepareOrders({ - solver, - tokens, - toToken: toTokenAddress, - minValue, - leftover, - maxFeePercent, - validity, - priceSlippageBps, - feeSlippageBps, - receiver, - authenticator, - settlement, - settlementDeploymentBlock, - network, - usdReference, - hre, - api, - dryRun, - gasEstimator, - domainSeparator, - maxOrders, -}: SelfSellInput): Promise<{ - orders: OrderDetails[]; - finalSettlement: EncodedSettlement | null; -}> { - const vaultRelayer: Promise = settlement.vaultRelayer(); - - let solverForSimulation: string; - if (await authenticator.isSolver(solver.address)) { - solverForSimulation = solver.address; - } else { - const message = - "Current account is not a solver. Only a solver can execute `settle` in the settlement contract."; - if (!dryRun) { - throw Error(message); - } else { - solverForSimulation = (await getSolvers(authenticator))[0]; - console.log(message); - if (solverForSimulation === undefined) { - throw new Error( - `There are no valid solvers for network ${network}, settlements are not possible`, - ); - } - } - } - - if (tokens === undefined) { - console.log("Recovering list of traded tokens..."); - ({ tokens } = await getAllTradedTokens( - settlement, - settlementDeploymentBlock, - "latest", - hre, - )); - } - - // TODO: remove once native asset orders are fully supported. - assertNotBuyingNativeAsset(toTokenAddress); - // todo: support dumping ETH by wrapping them - if (tokens.includes(BUY_ETH_ADDRESS)) { - throw new Error( - `Dumping the native token is not supported. Remove the ETH flag address ${BUY_ETH_ADDRESS} from the list of tokens to dump.`, - ); - } - const erc20 = await erc20Token(toTokenAddress, hre); - if (erc20 === null) { - throw new Error( - `Input toToken at address ${toTokenAddress} is not a valid Erc20 token.`, - ); - } - const toToken: Erc20Token = erc20; - - // TODO: send same token to receiver - if (tokens.includes(toToken.address)) { - throw new Error( - `Selling toToken is not yet supported. Remove ${toToken.address} from the list of tokens to dump.`, - ); - } - - // TODO: add eth orders - // TODO: split large transaction in batches - let orders = await getOrders({ - tokens, - toToken, - settlement, - vaultRelayer: await vaultRelayer, - minValue, - leftover, - hre, - usdReference, - receiver, - api, - validity, - maxFeePercent, - priceSlippageBps, - feeSlippageBps, - domainSeparator, - solverForSimulation, - }); - orders.sort((lhs, rhs) => { - const diff = BigNumber.from(lhs.order.buyAmount).sub(rhs.order.buyAmount); - return diff.isZero() ? 0 : diff.isNegative() ? -1 : 1; - }); - - const oneEth = utils.parseEther("1"); - const [oneEthUsdValue, gasPrice] = await Promise.all([ - usdValueOfEth(oneEth, usdReference, network, api), - gasEstimator.gasPriceEstimate(), - ]); - // Note: we don't add the gas fee when generating `orders` because we want to - // fetch gas prices at the last possible time to limit gas fluctuations. - orders = orders.map((o) => { - const marginalGasCost = gasPrice - .mul(o.extraGas) - .mul(oneEthUsdValue) - .div(oneEth); - return { ...o, feeUsd: o.feeUsd.add(marginalGasCost) }; - }); - orders = orders.filter( - ({ feeUsd, sellAmountUsd, sellToken, balance, balanceUsd }) => { - const approxUsdValue = Number(sellAmountUsd.toString()); - const approxTotalFee = Number(feeUsd); - const feePercent = (100 * approxTotalFee) / approxUsdValue; - if (feePercent > maxFeePercent) { - console.log( - ignoredTokenMessage( - [sellToken, balance], - `gas plus trade fee is too high (${feePercent.toFixed( - 2, - )}% of the traded amount)`, - [usdReference, balanceUsd], - ), - ); - return false; - } - - return true; - }, - ); - - if (orders.length === 0) { - console.log("No tokens to sell."); - return { orders: [], finalSettlement: null }; - } - if (orders.length > maxOrders) { - console.log(`Truncating total of ${orders.length} order to ${maxOrders}`); - // Remove the least profitable orders - orders = orders - .sort((o1, o2) => { - const first_proceeds = o1.sellAmountUsd.sub(o1.feeUsd); - const second_proceeds = o2.sellAmountUsd.sub(o2.feeUsd); - // We want to sort in descending order - return first_proceeds.lt(second_proceeds) ? 1 : -1; - }) - .slice(0, maxOrders); - } - displayOrders(orders, usdReference, toToken); - - const { finalSettlement, transactionEthCost, transactionUsdCost, soldValue } = - await computeSettlementWithPrice({ - orders, - gasPrice, - solverForSimulation, - settlement, - vaultRelayer: await vaultRelayer, - network, - usdReference, - api, - hre, - }); - - console.log( - `The settlement transaction will cost approximately ${formatGasCost( - transactionEthCost, - transactionUsdCost, - network, - usdReference, - )} and will create ${ - orders.length - } orders for an estimated total value of ${formatUsdValue( - soldValue, - usdReference, - )} USD. The proceeds of the orders will be sent to ${receiver}.`, - ); - - return { orders, finalSettlement }; -} - -interface SubmitOrderToApiInput { - orders: OrderDetails[]; - settlement: Contract; - api: Api; - hre: HardhatRuntimeEnvironment; - dryRun: boolean; - doNotPrompt?: boolean; -} -async function submitOrdersToApi({ - orders, - settlement, - hre, - api, - dryRun, - doNotPrompt, -}: SubmitOrderToApiInput) { - if ( - dryRun || - !(doNotPrompt || (await prompt(hre, "Submit orders to API?"))) - ) { - return; - } - - const from = settlement.address; - const preSignSignature: PreSignSignature = { - scheme: SigningScheme.PRESIGN, - data: from, - }; - for (const order of orders) { - console.log( - `Posting order selling ${ - order.sellToken.symbol ?? order.sellToken.address - }...`, - ); - try { - const apiOrderUid = await api.placeOrder({ - order: order.order, - signature: preSignSignature, - from, - }); - console.log(`Successfully created order with uid ${apiOrderUid}`); - if (apiOrderUid != order.orderUid) { - throw new Error( - "CoW Swap API returns different orderUid than what is used to presign the order. This order will not be settled and the code should be checked for bugs.", - ); - } - } catch (error) { - if ( - error instanceof Error && - (error as CallError)?.apiError !== undefined - ) { - // not null because of the condition in the if statement above - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { errorType, description } = (error as CallError).apiError!; - console.error( - `Failed submitting order selling ${ - order.sellToken.symbol ?? order.sellToken.address - }, the server returns ${errorType} (${description})`, - ); - console.error(`Order details: ${JSON.stringify(order)}`); - } else { - throw error; - } - } - } -} - -export async function selfSell(input: SelfSellInput): Promise { - let orders, finalSettlement; - try { - ({ orders, finalSettlement } = await prepareOrders(input)); - } catch (error) { - console.error( - "Script failed execution but no irreversible operations were performed", - ); - console.error(error); - throw error; - } - - if (finalSettlement === null) { - return []; - } - - await submitOrdersToApi({ - orders, - settlement: input.settlement, - api: input.api, - hre: input.hre, - dryRun: input.dryRun, - doNotPrompt: input.doNotPrompt, - }); - - if (input.dryRun) { - console.log("Not sending transaction in dryRun mode"); - } else if (input.solverIsSafe) { - const settlementData = input.settlement.interface.encodeFunctionData( - "settle", - finalSettlement, - ); - const safeTxUrl = await proposeTransaction( - input.hre, - input.hre.network.name, - { - to: input.settlement.address, - data: settlementData, - authoringSafe: input.solver.address, - }, - ); - console.log(`Sign settlement transaction in the Safe UI: ${safeTxUrl}`); - if (input.notifySlackChannel) { - await sendSlackMessage(input.notifySlackChannel, safeTxUrl); - } - } else { - await submitSettlement({ - ...input, - settlementContract: input.settlement, - encodedSettlement: finalSettlement, - }); - } - - return orders.map((o) => o.sellToken.address); -} - -const setupSelfSellTask: () => void = () => - task( - "self-sell", - "Sets up sell orders for the entire balance of the specified tokens from the settlement contract", - ) - .addOptionalParam( - "origin", - "Address from which to create the orders. If not specified, it defaults to the first provided account", - ) - .addOptionalParam( - "minValue", - "If specified, sets a minimum USD value required to sell the balance of a token.", - "0", - types.string, - ) - .addOptionalParam( - "leftover", - "If specified, selling leaves an amount of each token of USD value specified with this flag.", - "0", - types.string, - ) - .addOptionalParam( - "validity", - `How long the sell orders will be valid after their creation in seconds. It cannot be larger than ${MAX_ORDER_VALIDITY_SECONDS}`, - 20 * 60, - types.int, - ) - .addOptionalParam( - "feeSlippageBps", - "The slippage in basis points to account for changes in the trading fees between when the order is created and when it's executed by CoW Swap", - 1000, - types.int, - ) - .addOptionalParam( - "priceSlippageBps", - "The slippage in basis points for selling the dumped tokens", - 10, - types.int, - ) - .addOptionalParam( - "maxFeePercent", - "If the fees involved in creating a sell order (gas & trading fees) are larger than this percent of the sold amount, the token is not sold.", - 5, - types.float, - ) - .addOptionalParam( - "apiUrl", - "If set, the script contacts the API using the given url. Otherwise, the default prod url for the current network is used", - ) - .addParam("receiver", "The receiver of the sold tokens.") - .addFlag( - "dryRun", - "Just simulate the settlement instead of executing the transaction on the blockchain.", - ) - .addFlag( - "doNotPrompt", - "Automatically propose/execute the transaction without asking for confirmation.", - ) - .addFlag( - "blocknativeGasPrice", - "Use BlockNative gas price estimates for transactions.", - ) - .addParam("toToken", "All input tokens will be dumped to this token.") - .addFlag( - "safe", - "Whether the solver is a Safe and the script should propose the transaction to the Safe UI instead of sending a transaction directly", - ) - .addOptionalParam( - "notifySlackChannel", - "The slack channel id to send the proposed transaction to (requires SLACK_TOKEN env variable to be set)", - ) - .addOptionalParam( - "maxOrders", - "Limit the amount of orders per settlement to this value.", - 50, - types.int, - ) - .addOptionalVariadicPositionalParam( - "tokens", - "The list of tokens to sell. If unspecified, the script will generate this list automatically.", - ) - .setAction( - async ( - { - origin, - toToken, - minValue, - leftover, - maxFeePercent, - priceSlippageBps, - feeSlippageBps, - validity, - receiver: inputReceiver, - dryRun, - apiUrl, - blocknativeGasPrice, - safe, - notifySlackChannel, - doNotPrompt, - maxOrders, - tokens, - }, - hre: HardhatRuntimeEnvironment, - ) => { - const network = hre.network.name; - if (!isSupportedNetwork(network)) { - throw new Error(`Unsupported network ${network}`); - } - const gasEstimator = createGasEstimator(hre, { - blockNative: blocknativeGasPrice, - }); - const api = new Api(network, apiUrl ?? Environment.Prod); - const receiver = utils.getAddress(inputReceiver); - const [authenticator, settlementDeployment, solver, chainId] = - await Promise.all([ - getDeployedContract("GPv2AllowListAuthentication", hre), - hre.deployments.get("GPv2Settlement"), - getSignerOrAddress(hre, origin), - hre.ethers.provider.getNetwork().then((n) => n.chainId), - ]); - const settlement = new Contract( - settlementDeployment.address, - settlementDeployment.abi, - ).connect(hre.ethers.provider); - const settlementDeploymentBlock = - settlementDeployment.receipt?.blockNumber ?? 0; - const domainSeparator = domain(chainId, settlement.address); - console.log(`Using account ${solver.address}`); - - if (validity > MAX_ORDER_VALIDITY_SECONDS) { - throw new Error("Order validity too large"); - } - - if (tokens == undefined || tokens.length === 0) { - tokens = await getTokensWithBalanceAbove({ - chainId, - settlementContract: settlementDeployment.address, - minValueUsd: parseInt(minValue), - }); - } - // Exclude the toToken if needed, as we can not sell it for itself (buyToken is not allowed to equal sellToken) - tokens = tokens.filter( - (token: string) => - utils.getAddress(token) != utils.getAddress(toToken), - ); - - await selfSell({ - solver, - tokens, - toToken, - minValue, - leftover, - receiver, - maxFeePercent, - priceSlippageBps, - feeSlippageBps, - validity, - authenticator, - settlement, - settlementDeploymentBlock, - network, - usdReference: REFERENCE_TOKEN[network], - hre, - api, - dryRun, - gasEstimator, - domainSeparator, - solverIsSafe: safe, - notifySlackChannel, - doNotPrompt, - maxOrders, - }); - }, - ); - -export { setupSelfSellTask }; diff --git a/src/tasks/setApprovals.ts b/src/tasks/setApprovals.ts deleted file mode 100644 index 35025da6..00000000 --- a/src/tasks/setApprovals.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { promises as fs } from "fs"; - -import "@nomiclabs/hardhat-ethers"; -import { BigNumber } from "ethers"; -import { task, types } from "hardhat/config"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { SettlementEncoder } from "../ts"; - -import { getDeployedContract } from "./ts/deployment"; -import { createGasEstimator, gweiToWei } from "./ts/gas"; -import { prompt } from "./ts/tui"; - -interface Approval { - spender: string; - token: string; - amount: BigNumber; -} - -interface Args { - input: string; - dryRun: boolean; - gasInGwei: number; -} - -async function setApprovals( - { input, dryRun, gasInGwei }: Args, - hre: HardhatRuntimeEnvironment, -) { - const settlement = await getDeployedContract("GPv2Settlement", hre); - const [signer] = await hre.ethers.getSigners(); - - //Instantiate ERC20 ABI - const IERC20 = await hre.artifacts.readArtifact( - "src/contracts/interfaces/IERC20.sol:IERC20", - ); - const token = new hre.ethers.utils.Interface(IERC20.abi); - - // Load approval list and encode interaction for each entry - const approvals: Approval[] = JSON.parse(await fs.readFile(input, "utf-8")); - const encoder = new SettlementEncoder({}); - approvals.forEach((approval) => { - encoder.encodeInteraction({ - target: approval.token, - callData: token.encodeFunctionData("approve", [ - approval.spender, - approval.amount, - ]), - }); - }); - const finalSettlement = encoder.encodedSettlement({}); - const gasEstimator = createGasEstimator(hre, { - blockNative: false, - }); - const gasPrice = - gasInGwei > 0 - ? { - maxFeePerGas: gweiToWei(gasInGwei), - maxPriorityFeePerGas: gweiToWei(1.1), - } - : await gasEstimator.txGasPrice(); - - // settle the transaction - if ( - !dryRun && - (await prompt(hre, `Submit with gas price ${gasPrice.maxFeePerGas}?`)) - ) { - const response = await settlement - .connect(signer) - .settle(...finalSettlement, gasPrice); - console.log( - "Transaction submitted to the blockchain. Waiting for acceptance in a block...", - ); - const receipt = await response.wait(1); - console.log( - `Transaction successfully executed. Transaction hash: ${receipt.transactionHash}`, - ); - } else { - const settlementData = settlement.interface.encodeFunctionData( - "settle", - finalSettlement, - ); - console.log(settlementData); - } -} - -const setupSetApprovalsTask: () => void = () => { - task( - "set-approvals", - "Given a file containing a list of tokens and spenders, sets allowances on behalf of the settlement contract", - ) - .addPositionalParam( - "input", - `A json file containing a list of entries with token and spender field`, - ) - .addFlag( - "dryRun", - "Just simulate the settlement instead of executing the transaction on the blockchain.", - ) - .addOptionalParam( - "gasInGwei", - "Fix a gas price instead of using the native gas estimator", - 0, - types.int, - ) - .setAction(setApprovals); -}; - -export { setupSetApprovalsTask }; diff --git a/src/tasks/solvers.ts b/src/tasks/solvers.ts deleted file mode 100644 index 5c779c60..00000000 --- a/src/tasks/solvers.ts +++ /dev/null @@ -1,157 +0,0 @@ -import "hardhat-deploy"; -import "@nomiclabs/hardhat-ethers"; - -import { subtask, task } from "hardhat/config"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { getDeployedContract } from "./ts/deployment"; -import { getNamedSigner } from "./ts/signers"; -import { getSolvers } from "./ts/solver"; -import { transactionUrl } from "./ts/tui"; - -const solversTaskList = [ - "add", - "check", - "remove", - "list", - "setManager", -] as const; -type SolversTasks = (typeof solversTaskList)[number]; - -interface Args { - address?: string; - printTransaction: boolean; -} - -async function addSolver(args: Args, hre: HardhatRuntimeEnvironment) { - await performSolverManagement("addSolver", args, hre); -} - -const removeSolver = async (args: Args, hre: HardhatRuntimeEnvironment) => { - await performSolverManagement("removeSolver", args, hre); -}; - -const setManager = async (args: Args, hre: HardhatRuntimeEnvironment) => { - await performSolverManagement("setManager", args, hre); -}; - -async function performSolverManagement( - method: "addSolver" | "removeSolver" | "setManager", - { address, printTransaction }: Args, - hre: HardhatRuntimeEnvironment, -): Promise { - const authenticator = await getDeployedContract( - "GPv2AllowListAuthentication", - hre, - ); - - if (printTransaction) { - const data = authenticator.interface.encodeFunctionData(method, [address]); - console.log(`\`${method}\` transaction:`); - console.log(`To: ${authenticator.address}`); - console.log(`Data: ${data}`); - } else { - const owner = await getNamedSigner(hre, "manager"); - const tx = await authenticator.connect(owner)[method](address); - console.log(transactionUrl(hre, tx)); - await tx.wait(); - console.log(`Executed \`${method}\`.`); - } -} - -const isSolver = async (solver: string, hre: HardhatRuntimeEnvironment) => { - const authenticator = await getDeployedContract( - "GPv2AllowListAuthentication", - hre, - ); - - console.log( - `${solver} is ${ - (await authenticator.isSolver(solver)) ? "" : "NOT " - }a solver.`, - ); -}; - -async function listSolvers(hre: HardhatRuntimeEnvironment) { - const authenticator = await getDeployedContract( - "GPv2AllowListAuthentication", - hre, - ); - - console.log((await getSolvers(authenticator)).join("\n")); -} - -const setupSolversTask: () => void = () => { - task( - "solvers", - "Reads and changes the state of the authenticator in CoW Protocol.", - ) - .addPositionalParam( - "subtask", - `The action to execute on the authenticator. Allowed subtasks: ${solversTaskList.join( - ", ", - )}`, - ) - .addOptionalPositionalParam( - "address", - "The solver account to add, remove, or check", - ) - .addFlag( - "printTransaction", - "Prints the transaction to standard out when adding or removing solvers.", - ) - .setAction(async (taskArguments, { run }) => { - const { subtask } = taskArguments; - if (solversTaskList.includes(subtask)) { - delete taskArguments.subtask; - await run(`solvers-${subtask}`, taskArguments); - } else { - throw new Error(`Invalid solver subtask ${subtask}.`); - } - }); - - subtask( - "solvers-add", - "Adds a solver to the list of allowed solvers in GPv2.", - ) - .addPositionalParam("address", "The solver account to add.") - .addFlag("printTransaction", "Prints the transaction to standard out.") - .setAction(addSolver); - - subtask( - "solvers-remove", - "Removes a solver from the list of allowed solvers in GPv2.", - ) - .addPositionalParam("address", "The solver account to remove.") - .addFlag("printTransaction", "Prints the transaction to standard out.") - .setAction(removeSolver); - - subtask( - "solvers-setManager", - "Changes the manager who is responsible to add and remove solvers.", - ) - .addPositionalParam( - "address", - "The address that is going to replace the current manager.", - ) - .addFlag("printTransaction", "Prints the transaction to standard out.") - .setAction(setManager); - - subtask( - "solvers-check", - "Checks that an address is registered as a solver of GPv2.", - ) - .addPositionalParam("address", "The solver account to check.") - .setAction(async ({ address }, hardhatRuntime) => { - await isSolver(address, hardhatRuntime); - }); - - subtask( - "solvers-list", - "List all currently registered solvers of GPv2.", - ).setAction(async (_, hardhatRuntime) => { - await listSolvers(hardhatRuntime); - }); -}; - -export { setupSolversTask }; diff --git a/src/tasks/tenderly.ts b/src/tasks/tenderly.ts deleted file mode 100644 index 647d439b..00000000 --- a/src/tasks/tenderly.ts +++ /dev/null @@ -1,56 +0,0 @@ -import "hardhat-deploy"; -import "@nomiclabs/hardhat-ethers"; -import "@tenderly/hardhat-tenderly"; - -import { task } from "hardhat/config"; -import { Deployment } from "hardhat-deploy/types"; - -function separateProxiedContracts(allDeployments: Record): { - proxied: string[]; - unproxied: string[]; -} { - const proxied = Object.entries(allDeployments) - .filter(([, deployment]) => deployment.implementation !== undefined) - .map(([name]) => name); - - const proxyRelatedDeployments = proxied - .map((name) => [name, name + "_Implementation", name + "_Proxy"]) - .flat(); - const unproxied = Object.keys(allDeployments).filter( - (name) => !proxyRelatedDeployments.includes(name), - ); - - return { proxied, unproxied }; -} - -const setupTenderlyTask: () => void = () => { - task("tenderly", "Verifies smart contract code on Tenderly.").setAction( - async (_, { deployments, tenderly }) => { - const allDeployments = await deployments.all(); - const { proxied, unproxied } = separateProxiedContracts(allDeployments); - - const verificationInput = []; - verificationInput.push( - ...unproxied.map((name) => ({ - name, - address: allDeployments[name].address, - })), - ); - verificationInput.push( - ...proxied.map((name) => ({ - name, - address: allDeployments[name + "_Implementation"].address, - })), - ); - - // Note: the source code of the actual proxy is not verified. Supporting - // it would require handling: - // - code that not compiled in the project - // - custom Solc versions in tenderly-verify - - await tenderly.verify(...verificationInput); - }, - ); -}; - -export { setupTenderlyTask }; diff --git a/src/tasks/ts/gas.ts b/src/tasks/ts/gas.ts deleted file mode 100644 index 868b5d50..00000000 --- a/src/tasks/ts/gas.ts +++ /dev/null @@ -1,102 +0,0 @@ -import axios from "axios"; -import { BigNumber, ethers } from "ethers"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -export type FeeData = Partial; - -/** - * Gas estimator interface. - */ -export interface IGasEstimator { - /** - * Computes the estimated gas price for a given transaction. - */ - gasPriceEstimate(): Promise; - - /** - * Computes the optimal transaction gas price options to use. - */ - txGasPrice(): Promise; -} - -export function createGasEstimator( - hre: HardhatRuntimeEnvironment, - options: { - blockNative: boolean; - }, -): IGasEstimator { - if (options.blockNative) { - if (hre.network.name !== "mainnet") { - throw new Error(`BlockNative does not support ${hre.network.name}`); - } - return new BlockNativeGasEstimator(); - } else { - return new ProviderGasEstimator(hre.ethers.provider); - } -} - -export class ProviderGasEstimator implements IGasEstimator { - constructor(private provider: ethers.providers.Provider) {} - - gasPriceEstimate(): Promise { - return this.provider.getGasPrice(); - } - - txGasPrice(): Promise { - return Promise.resolve({}); - } -} - -// We just use the API that the gas price browser page uses to avoid dealing -// with API keys and rate limiting. -const BLOCKNATIVE_URL = "https://blocknative-api.herokuapp.com/data"; - -interface EstimatedPrice { - confidence: number; - price: number; - maxPriorityFeePerGas: number; - maxFeePerGas: number; -} - -interface BlockPrices { - estimatedPrices: EstimatedPrice[]; -} - -export class BlockNativeGasEstimator implements IGasEstimator { - constructor(public confidence: number = 90) {} - - private async queryEstimatedPrice(): Promise { - const response = await axios.get(BLOCKNATIVE_URL); - const { estimatedPrices }: BlockPrices = response.data; - estimatedPrices.sort((a, b) => a.confidence - b.confidence); - const price = estimatedPrices.find( - (price) => price.confidence >= this.confidence, - ); - if (price === undefined) { - throw new Error( - `no price with confidence greater than ${this.confidence}`, - ); - } - - return price; - } - - async gasPriceEstimate(): Promise { - const { price } = await this.queryEstimatedPrice(); - return gweiToWei(price); - } - - async txGasPrice(): Promise { - const { maxFeePerGas, maxPriorityFeePerGas } = - await this.queryEstimatedPrice(); - - return { - maxFeePerGas: gweiToWei(maxFeePerGas), - maxPriorityFeePerGas: gweiToWei(maxPriorityFeePerGas), - }; - } -} - -export function gweiToWei(amount: number): BigNumber { - return ethers.utils.parseUnits(amount.toFixed(9), 9); -} diff --git a/src/tasks/ts/rate_limits.ts b/src/tasks/ts/rate_limits.ts deleted file mode 100644 index d70104a5..00000000 --- a/src/tasks/ts/rate_limits.ts +++ /dev/null @@ -1,89 +0,0 @@ -const MAX_PARALLEL_INSTRUCTIONS = 20; - -const LINE_CLEARING_ENABLED = - process.stdout.isTTY && - process.stdout.clearLine !== undefined && - process.stdout.cursorTo !== undefined; - -export interface DisappearingLogFunctions { - consoleWarn: typeof console.warn; - consoleLog: typeof console.log; - consoleError: typeof console.error; -} - -interface VanishingProgressMessage { - message: string; -} - -interface RateLimitOptionalParameters { - message?: string; - rateLimit?: number; -} - -function createDisappearingLogFunctions( - vanishingProgressMessage: VanishingProgressMessage, -): DisappearingLogFunctions { - // note: if the message contains more than a line, only the last line is going - // to vanish - function clearable( - logFunction: (...input: LogInput) => void, - ): (...input: LogInput) => void { - return (...args: LogInput) => { - if (LINE_CLEARING_ENABLED) { - process.stdout.clearLine(0); - } - logFunction(...args); - if (LINE_CLEARING_ENABLED) { - process.stdout.write(vanishingProgressMessage.message); - process.stdout.cursorTo(0); - } - }; - } - return { - consoleWarn: clearable(console.warn), - consoleLog: clearable(console.log), - consoleError: clearable(console.error), - }; -} - -export async function promiseAllWithRateLimit( - instructions: ((logFunctions: DisappearingLogFunctions) => Promise)[], - { rateLimit: rateLimitInput, message }: RateLimitOptionalParameters = {}, -): Promise { - const rateLimit = Math.floor(rateLimitInput ?? MAX_PARALLEL_INSTRUCTIONS); - if (rateLimit < 1) { - throw new Error(`Rate limit must be one or larger, found ${rateLimit}`); - } - const output: T[] = []; - const vanishingProgressMessage = { message: "" }; - const disappearingLogFunctions = createDisappearingLogFunctions( - vanishingProgressMessage, - ); - for (let step = 0; step < instructions.length / rateLimit; step++) { - const remainingInstructions = instructions.length - rateLimit * step; - const instructionsInBatch = - remainingInstructions >= rateLimit ? rateLimit : remainingInstructions; - vanishingProgressMessage.message = `Processing steps ${ - step * rateLimit + 1 - } to ${step * rateLimit + instructionsInBatch} of ${instructions.length}${ - message !== undefined ? ` (${message})` : "" - }...`; - if (LINE_CLEARING_ENABLED) { - process.stdout.write(vanishingProgressMessage.message); - process.stdout.cursorTo(0); - } - output.push( - ...(await Promise.all( - Array(instructionsInBatch) - .fill(undefined) - .map((_, i) => - instructions[step * rateLimit + i](disappearingLogFunctions), - ), - )), - ); - } - if (LINE_CLEARING_ENABLED) { - process.stdout.clearLine(0); - } - return output; -} diff --git a/src/tasks/ts/solver.ts b/src/tasks/ts/solver.ts deleted file mode 100644 index faaf6849..00000000 --- a/src/tasks/ts/solver.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Contract } from "ethers"; - -export async function getSolvers(authenticator: Contract): Promise { - const addedSolvers: string[] = ( - await authenticator.queryFilter(authenticator.filters.SolverAdded()) - ).map((log) => { - // "SolverAdded" always has the argument "solver" - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return log.args!.solver; - }); - const isSolver = await Promise.all( - addedSolvers.map((solver) => authenticator.isSolver(solver)), - ); - return addedSolvers.filter((_, i) => isSolver[i]); -} diff --git a/src/tasks/ts/table.ts b/src/tasks/ts/table.ts deleted file mode 100644 index 5e1166cc..00000000 --- a/src/tasks/ts/table.ts +++ /dev/null @@ -1,77 +0,0 @@ -import chalk from "chalk"; - -export enum Align { - Left, - Right, -} - -interface ColumnOptions { - align?: Align; - maxWidth?: number; -} - -function cellText(text: string, size: number, align = Align.Left): string { - let inner; - if (text.length > size - 2) { - inner = text.slice(0, size - 5) + "..."; - } else { - if (align == Align.Right) { - inner = text.padStart(size - 2, " "); - } else { - inner = text.padEnd(size - 2, " "); - } - } - - return " " + inner + " "; -} - -// shrinks column sizes to fit the content -function columnWidths( - header: Record, - entries: Record[], - maxWidth: Partial>> = {}, -): Record { - const longestEntryLenght = (key: Key) => - entries.reduce((longest, entry) => Math.max(longest, entry[key].length), 0); - const width: Partial> = {}; - for (const key in header) { - // pad with a space left and right (+2) - width[key] = - Math.min( - Math.max(header[key].length, longestEntryLenght(key)), - maxWidth[key]?.maxWidth === undefined - ? Infinity - : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - maxWidth[key]!.maxWidth!, - ) + 2; - } - return width as Record; -} - -export function displayTable( - header: Record, - entries: Record[], - order: Key[] | readonly Key[], - keyOptions: Partial> = {}, -): void { - const width = columnWidths(header, entries, keyOptions); - console.log( - order - .map((key: Key) => - chalk.cyan(cellText(header[key], width[key], Align.Left)), - ) - .join(chalk.gray("|")), - ); - console.log( - chalk.gray(order.map((key: Key) => "-".repeat(width[key])).join("+")), - ); - for (const entry of entries) { - console.log( - order - .map((key: Key) => - cellText(entry[key], width[key], keyOptions[key]?.align), - ) - .join(chalk.gray("|")), - ); - } -} diff --git a/src/tasks/ts/tokens.ts b/src/tasks/ts/tokens.ts deleted file mode 100644 index dc800ac2..00000000 --- a/src/tasks/ts/tokens.ts +++ /dev/null @@ -1,151 +0,0 @@ -import WethNetworks from "canonical-weth/networks.json"; -import { - BigNumber, - BigNumberish, - Contract, - ContractTransaction, - providers, - Signer, -} from "ethers"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { SupportedNetwork, isSupportedNetwork } from "./deployment"; - -export interface Erc20Token { - contract: Contract; - symbol?: string; - decimals?: number; - address: string; -} - -export interface NativeToken { - symbol: string; - decimals: number; - provider: providers.JsonRpcProvider; -} - -export const NATIVE_TOKEN_SYMBOL: Record = - { - hardhat: "ETH", - mainnet: "ETH", - rinkeby: "ETH", - goerli: "ETH", - sepolia: "ETH", - xdai: "xDAI", - }; - -export const WRAPPED_NATIVE_TOKEN_ADDRESS: Record = { - mainnet: WethNetworks.WETH9[1].address, - rinkeby: WethNetworks.WETH9[4].address, - sepolia: "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", - goerli: "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6", - xdai: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", -}; - -export function isNativeToken( - token: Erc20Token | NativeToken, -): token is NativeToken { - return (token as Erc20Token).contract === undefined; -} - -export function displayName(token: Erc20Token | NativeToken): string { - if (isNativeToken(token)) { - return token.symbol; - } else { - return token.symbol ? `${token.symbol} (${token.address})` : token.address; - } -} - -export async function balanceOf( - token: Erc20Token | NativeToken, - user: string, -): Promise { - const balanceFunction = isNativeToken(token) - ? token.provider.getBalance - : token.contract.balanceOf; - return BigNumber.from(await balanceFunction(user)); -} - -export async function estimateTransferGas( - token: Erc20Token | NativeToken, - from: string, - to: string, - amount: BigNumberish, -): Promise { - if (isNativeToken(token)) { - return await token.provider.estimateGas({ - from, - to, - value: amount, - }); - } else { - return await token.contract.estimateGas["transfer"](to, amount, { - from, - }); - } -} - -export async function transfer( - token: Erc20Token | NativeToken, - from: Signer, - to: string, - amount: BigNumberish, -): Promise { - const transferFunction: ( - to: string, - amount: BigNumberish, - ) => ContractTransaction | providers.TransactionResponse = isNativeToken( - token, - ) - ? (to, value) => from.sendTransaction({ to, value }) - : token.contract.connect(from).transfer; - return await transferFunction(to, amount); -} - -export function nativeToken({ - ethers, - network, -}: HardhatRuntimeEnvironment): NativeToken { - if (network.name !== "hardhat" && !isSupportedNetwork(network.name)) { - throw new Error( - `Cannot retrieve native token for unsupported network ${network.name}`, - ); - } - return { - symbol: NATIVE_TOKEN_SYMBOL[network.name], - decimals: 18, // assumption: every network supported by our protocol uses an 18-decimal native token - provider: ethers.provider, - }; -} - -export async function erc20Token( - address: string, - hre: HardhatRuntimeEnvironment, -): Promise { - const IERC20 = await hre.artifacts.readArtifact( - "src/contracts/interfaces/IERC20.sol:IERC20", - ); - const contract = new Contract(address, IERC20.abi, hre.ethers.provider); - const [symbol, decimals] = await Promise.all([ - contract - .symbol() - .then((s: unknown) => (typeof s !== "string" ? null : s)) - .catch(() => null), - contract - .decimals() - .then((s: unknown) => BigNumber.from(s)) - .catch(() => null), - ]); - if (symbol === null || decimals === null) { - const code = await hre.ethers.provider.getCode(address); - if (code === "0x") { - return null; - } - } - return { - contract, - symbol, - decimals, - address, - }; -} diff --git a/src/tasks/ts/value.ts b/src/tasks/ts/value.ts deleted file mode 100644 index a8ddee56..00000000 --- a/src/tasks/ts/value.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { BigNumber, utils } from "ethers"; - -import { BUY_ETH_ADDRESS, OrderKind } from "../../ts"; -import { Api } from "../../ts/api"; -import { SupportedNetwork } from "../ts/deployment"; -import { - Erc20Token, - isNativeToken, - NativeToken, - WRAPPED_NATIVE_TOKEN_ADDRESS, -} from "../ts/tokens"; - -export interface ReferenceToken { - symbol: string; - decimals: number; - address: string; -} -export const REFERENCE_TOKEN: Record = { - rinkeby: { - symbol: "DAI", - decimals: 18, - address: "0x5592ec0cfb4dbc12d3ab100b257153436a1f0fea", - }, - goerli: { - symbol: "DAI", - decimals: 18, - address: "0xdc31Ee1784292379Fbb2964b3B9C4124D8F89C60", - }, - mainnet: { - symbol: "DAI", - decimals: 18, - address: "0x6b175474e89094c44da98b954eedeac495271d0f", - }, - sepolia: { - symbol: "DAI", - decimals: 18, - address: "0xB4F1737Af37711e9A5890D9510c9bB60e170CB0D", - }, - xdai: { - // todo: replace with XDAI when native token price queries will be supported - // by the services. - symbol: "WXDAI", - decimals: 18, - address: WRAPPED_NATIVE_TOKEN_ADDRESS.xdai, - }, -} as const; - -export async function usdValue( - token: string, - amount: BigNumber, - referenceToken: ReferenceToken, - api: Api, -): Promise { - return `${token}`.toLowerCase() != referenceToken.address.toLowerCase() - ? await api.estimateTradeAmount({ - sellToken: token, - buyToken: referenceToken.address, - amount, - kind: OrderKind.SELL, - }) - : amount; -} - -export async function usdValueOfEth( - amount: BigNumber, - referenceToken: ReferenceToken, - network: SupportedNetwork, - api: Api, -): Promise { - return await usdValue( - WRAPPED_NATIVE_TOKEN_ADDRESS[network], - amount, - referenceToken, - api, - ); -} - -export async function ethValue( - token: Erc20Token | NativeToken, - amount: BigNumber, - network: SupportedNetwork, - api: Api, -): Promise { - if ( - isNativeToken(token) || - token.address == WRAPPED_NATIVE_TOKEN_ADDRESS[network] // todo: remove when services support selling weth for eth - ) { - return amount; - } - return await api.estimateTradeAmount({ - sellToken: token.address, - buyToken: BUY_ETH_ADDRESS, - amount, - kind: OrderKind.SELL, - }); -} - -// Format amount so that it has exactly a fixed amount of decimals. -export function formatTokenValue( - amount: BigNumber, - actualDecimals: number, - targetDecimals: number, -): string { - const normalized = - targetDecimals <= actualDecimals - ? amount.div(BigNumber.from(10).pow(actualDecimals - targetDecimals)) - : amount.mul(BigNumber.from(10).pow(-actualDecimals + targetDecimals)); - const powDecimals = BigNumber.from(10).pow(targetDecimals); - return `${normalized.div(powDecimals).toString()}.${normalized - .mod(powDecimals) - .toString() - .padStart(targetDecimals, "0")}`; -} - -export function formatUsdValue( - amount: BigNumber, - usdReference: ReferenceToken, -): string { - return formatTokenValue(amount, usdReference.decimals, 2); -} - -export function formatGasCost( - amount: BigNumber, - usdAmount: BigNumber, - network: SupportedNetwork, - usdReference: ReferenceToken, -): string { - switch (network) { - case "mainnet": { - return `${utils.formatEther(amount)} ETH (${formatUsdValue( - usdAmount, - usdReference, - )} USD)`; - } - case "xdai": - return `${utils.formatEther(amount)} XDAI`; - default: - return `${utils.formatEther(amount)} ETH`; - } -} diff --git a/src/tasks/withdraw.ts b/src/tasks/withdraw.ts deleted file mode 100644 index 5d42aacc..00000000 --- a/src/tasks/withdraw.ts +++ /dev/null @@ -1,641 +0,0 @@ -import "@nomiclabs/hardhat-ethers"; - -import chalk from "chalk"; -import { BigNumber, constants, Contract, utils } from "ethers"; -import { task, types } from "hardhat/config"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { EncodedSettlement, SettlementEncoder } from "../ts"; -import { - Api, - ApiError, - CallError, - Environment, - LIMIT_CONCURRENT_REQUESTS, -} from "../ts/api"; - -import { - getDeployedContract, - isSupportedNetwork, - SupportedNetwork, -} from "./ts/deployment"; -import { createGasEstimator, IGasEstimator } from "./ts/gas"; -import { - DisappearingLogFunctions, - promiseAllWithRateLimit, -} from "./ts/rate_limits"; -import { getSolvers } from "./ts/solver"; -import { Align, displayTable } from "./ts/table"; -import { erc20Token, Erc20Token } from "./ts/tokens"; -import { - formatTokenValue, - formatUsdValue, - REFERENCE_TOKEN, - ReferenceToken, - usdValue, - usdValueOfEth, - formatGasCost, -} from "./ts/value"; -import { ignoredTokenMessage } from "./withdraw/messages"; -import { submitSettlement } from "./withdraw/settle"; -import { getSignerOrAddress, SignerOrAddress } from "./withdraw/signer"; -import { getAllTradedTokens } from "./withdraw/traded_tokens"; - -interface Withdrawal { - token: Erc20Token; - amount: BigNumber; - amountUsd: BigNumber; - balance: BigNumber; - balanceUsd: BigNumber; - gas: BigNumber; -} - -interface DisplayWithdrawal { - symbol: string; - balance: string; - amount: string; - value: string; - address: string; -} - -interface ComputeSettlementInput { - withdrawals: Omit[]; - receiver: string; - solverForSimulation: string; - settlement: Contract; - hre: HardhatRuntimeEnvironment; -} -async function computeSettlement({ - withdrawals, - receiver, - solverForSimulation, - settlement, - hre, -}: ComputeSettlementInput) { - const encoder = new SettlementEncoder({}); - withdrawals.forEach(({ token, amount }) => - encoder.encodeInteraction({ - target: token.address, - callData: token.contract.interface.encodeFunctionData("transfer", [ - receiver, - amount, - ]), - }), - ); - - const finalSettlement = encoder.encodedSettlement({}); - const gas = await settlement - .connect(hre.ethers.provider) - .estimateGas.settle(...finalSettlement, { - from: solverForSimulation, - }); - return { - finalSettlement, - gas, - }; -} - -interface ComputeSettlementWithPriceInput extends ComputeSettlementInput { - gasPrice: BigNumber; - network: SupportedNetwork; - usdReference: ReferenceToken; - api: Api; -} -async function computeSettlementWithPrice({ - withdrawals, - receiver, - solverForSimulation, - settlement, - gasPrice, - network, - usdReference, - api, - hre, -}: ComputeSettlementWithPriceInput) { - const { gas, finalSettlement } = await computeSettlement({ - withdrawals, - receiver, - solverForSimulation, - settlement, - hre, - }); - - const transactionEthCost = gas.mul(gasPrice); - // The following ternary operator is used as a hack to avoid having to - // set expectations for the gas value in the tests, since gas values - // could easily change with any minor changes to the tests - const transactionUsdCost = - hre.network.name === "hardhat" - ? constants.Zero - : await usdValueOfEth(transactionEthCost, usdReference, network, api); - const withdrawnValue = withdrawals.reduce( - (sum, { amountUsd }) => sum.add(amountUsd), - constants.Zero, - ); - - return { - finalSettlement, - transactionEthCost, - transactionUsdCost, - gas, - withdrawnValue, - }; -} - -export interface GetBalanceToWithdrawInput { - token: Erc20Token; - usdReference: ReferenceToken; - settlement: Contract; - api: Api; - leftoverWei: BigNumber; - minValueWei: BigNumber; - consoleLog: typeof console.log; -} -export interface BalanceOutput { - netAmount: BigNumber; - netAmountUsd: BigNumber; - balance: BigNumber; - balanceUsd: BigNumber; -} -export async function getAmounts({ - token, - usdReference, - settlement, - api, - leftoverWei, - minValueWei, - consoleLog, -}: GetBalanceToWithdrawInput): Promise { - const balance = await token.contract.balanceOf(settlement.address); - if (balance.eq(0)) { - return null; - } - let balanceUsd; - try { - balanceUsd = await usdValue(token.address, balance, usdReference, api); - } catch (e) { - if (!(e instanceof Error)) { - throw e; - } - const errorData: ApiError = (e as CallError).apiError ?? { - errorType: "script internal error", - description: e?.message ?? "no details", - }; - consoleLog( - `Warning: price retrieval failed for token ${token.symbol} (${token.address}): ${errorData.errorType} (${errorData.description})`, - ); - balanceUsd = constants.Zero; - } - // Note: if balanceUsd is zero, then setting either minValue or leftoverWei - // to a nonzero value means that nothing should be withdrawn. If neither - // flag is set, then whether to withdraw does not depend on the USD value. - if ( - balanceUsd.lt(minValueWei.add(leftoverWei)) || - (balanceUsd.isZero() && !(minValueWei.isZero() && leftoverWei.isZero())) - ) { - consoleLog( - ignoredTokenMessage( - [token, balance], - "does not satisfy conditions on min value and leftover", - [usdReference, balanceUsd], - ), - ); - return null; - } - let netAmount; - let netAmountUsd; - if (balanceUsd.isZero()) { - // Note: minValueWei and leftoverWei are zero. Everything should be - // withdrawn. - netAmount = balance; - netAmountUsd = balanceUsd; - } else { - netAmount = balance.mul(balanceUsd.sub(leftoverWei)).div(balanceUsd); - netAmountUsd = balanceUsd.sub(leftoverWei); - } - return { netAmount, netAmountUsd, balance, balanceUsd }; -} - -interface GetWithdrawalsInput { - tokens: string[]; - settlement: Contract; - minValue: string; - leftover: string; - gasEmptySettlement: Promise; - hre: HardhatRuntimeEnvironment; - usdReference: ReferenceToken; - receiver: string; - solverForSimulation: string; - api: Api; -} -async function getWithdrawals({ - tokens, - settlement, - minValue, - leftover, - gasEmptySettlement, - hre, - usdReference, - receiver, - solverForSimulation, - api, -}: GetWithdrawalsInput): Promise { - const minValueWei = utils.parseUnits(minValue, usdReference.decimals); - const leftoverWei = utils.parseUnits(leftover, usdReference.decimals); - const computeWithdrawalInstructions = tokens.map( - (tokenAddress) => - async ({ consoleLog }: DisappearingLogFunctions) => { - const token = await erc20Token(tokenAddress, hre); - if (token === null) { - throw new Error( - `There is no valid ERC20 token at address ${tokenAddress}`, - ); - } - - const amounts = await getAmounts({ - token, - usdReference, - settlement, - api, - leftoverWei, - minValueWei, - consoleLog, - }); - if (amounts === null) { - return null; - } - - const withdrawalWithoutGas = { - token, - amount: amounts.netAmount, - amountUsd: amounts.netAmountUsd, - balance: amounts.balance, - balanceUsd: amounts.balanceUsd, - }; - let gas; - try { - ({ gas } = await computeSettlement({ - withdrawals: [withdrawalWithoutGas], - receiver, - solverForSimulation, - settlement, - hre, - })); - } catch (error) { - if (!(error instanceof Error)) { - throw error; - } - consoleLog( - ignoredTokenMessage( - [token, amounts.balance], - `cannot execute withdraw transaction (${error.message})`, - [usdReference, amounts.balanceUsd], - ), - ); - return null; - } - return { - ...withdrawalWithoutGas, - gas: gas.sub(await gasEmptySettlement), - }; - }, - ); - const processedWithdrawals: (Withdrawal | null)[] = - await promiseAllWithRateLimit(computeWithdrawalInstructions, { - message: "computing withdrawals", - rateLimit: LIMIT_CONCURRENT_REQUESTS, - }); - return processedWithdrawals.filter( - (withdrawal) => withdrawal !== null, - ) as Withdrawal[]; -} - -function formatWithdrawal( - withdrawal: Withdrawal, - usdReference: ReferenceToken, -): DisplayWithdrawal { - const formatDecimals = withdrawal.token.decimals ?? 18; - return { - address: withdrawal.token.address, - value: formatUsdValue(withdrawal.balanceUsd, usdReference), - balance: formatTokenValue(withdrawal.balance, formatDecimals, 18), - amount: formatTokenValue(withdrawal.amount, formatDecimals, 18), - symbol: withdrawal.token.symbol ?? "unknown token", - }; -} - -function displayWithdrawals( - withdrawals: Withdrawal[], - usdReference: ReferenceToken, -) { - const formattedWithdtrawals = withdrawals.map((w) => - formatWithdrawal(w, usdReference), - ); - const order = ["address", "value", "balance", "amount", "symbol"] as const; - const header = { - address: "address", - value: "balance (usd)", - balance: "balance", - amount: "withdrawn amount", - symbol: "symbol", - }; - console.log(chalk.bold("Amounts to withdraw:")); - displayTable(header, formattedWithdtrawals, order, { - value: { align: Align.Right }, - balance: { align: Align.Right, maxWidth: 30 }, - amount: { align: Align.Right, maxWidth: 30 }, - symbol: { maxWidth: 20 }, - }); - console.log(); -} - -interface WithdrawInput { - solver: SignerOrAddress; - tokens: string[] | undefined; - minValue: string; - leftover: string; - maxFeePercent: number; - receiver: string; - authenticator: Contract; - settlement: Contract; - settlementDeploymentBlock: number; - network: SupportedNetwork; - usdReference: ReferenceToken; - hre: HardhatRuntimeEnvironment; - api: Api; - dryRun: boolean; - gasEstimator: IGasEstimator; - doNotPrompt?: boolean | undefined; - requiredConfirmations?: number | undefined; -} - -async function prepareWithdrawals({ - solver, - tokens, - minValue, - leftover, - maxFeePercent, - receiver, - authenticator, - settlement, - settlementDeploymentBlock, - network, - usdReference, - hre, - api, - dryRun, - gasEstimator, -}: WithdrawInput): Promise<{ - withdrawals: Withdrawal[]; - finalSettlement: EncodedSettlement | null; -}> { - let solverForSimulation: string; - if (await authenticator.isSolver(solver.address)) { - solverForSimulation = solver.address; - } else { - const message = - "Current account is not a solver. Only a solver can withdraw funds from the settlement contract."; - if (!dryRun) { - throw Error(message); - } else { - solverForSimulation = (await getSolvers(authenticator))[0]; - console.log(message); - if (solverForSimulation === undefined) { - throw new Error( - `There are no valid solvers for network ${network}, withdrawing is not possible`, - ); - } - } - } - const gasEmptySettlement = computeSettlement({ - withdrawals: [], - receiver, - solverForSimulation, - settlement, - hre, - }).then(({ gas }) => gas); - - if (tokens === undefined) { - console.log("Recovering list of traded tokens..."); - ({ tokens } = await getAllTradedTokens( - settlement, - settlementDeploymentBlock, - "latest", - hre, - )); - } - - // TODO: add eth withdrawal - // TODO: split large transaction in batches - let withdrawals = await getWithdrawals({ - tokens, - settlement, - minValue, - leftover, - gasEmptySettlement, - hre, - usdReference, - receiver, - solverForSimulation, - api, - }); - withdrawals.sort((lhs, rhs) => { - const diff = lhs.balanceUsd.sub(rhs.balanceUsd); - return diff.isZero() ? 0 : diff.isNegative() ? -1 : 1; - }); - - const oneEth = utils.parseEther("1"); - const [oneEthUsdValue, gasPrice] = await Promise.all([ - usdValueOfEth(oneEth, usdReference, network, api), - gasEstimator.gasPriceEstimate(), - ]); - withdrawals = withdrawals.filter( - ({ token, balance, balanceUsd, amountUsd, gas }) => { - const approxUsdValue = Number(amountUsd.toString()); - const approxGasCost = Number( - gasPrice.mul(gas).mul(oneEthUsdValue).div(oneEth), - ); - const feePercent = (100 * approxGasCost) / approxUsdValue; - if (feePercent > maxFeePercent) { - console.log( - ignoredTokenMessage( - [token, balance], - `the gas cost is too high (${feePercent.toFixed( - 2, - )}% of the withdrawn amount)`, - [usdReference, balanceUsd], - ), - ); - return false; - } - - return true; - }, - ); - - if (withdrawals.length === 0) { - console.log("No tokens to withdraw."); - return { withdrawals: [], finalSettlement: null }; - } - displayWithdrawals(withdrawals, usdReference); - - const { - finalSettlement, - transactionEthCost, - transactionUsdCost, - withdrawnValue, - } = await computeSettlementWithPrice({ - withdrawals, - receiver, - gasPrice, - solverForSimulation, - settlement, - network, - usdReference, - api, - hre, - }); - - console.log( - `The transaction will cost approximately ${formatGasCost( - transactionEthCost, - transactionUsdCost, - network, - usdReference, - )} and will withdraw the balance of ${ - withdrawals.length - } tokens for an estimated total value of ${formatUsdValue( - withdrawnValue, - usdReference, - )} USD. All withdrawn funds will be sent to ${receiver}.`, - ); - - return { withdrawals, finalSettlement }; -} - -export async function withdraw(input: WithdrawInput): Promise { - let withdrawals, finalSettlement; - try { - ({ withdrawals, finalSettlement } = await prepareWithdrawals(input)); - } catch (error) { - console.log( - "Script failed execution but no irreversible operations were performed", - ); - console.log(error); - return null; - } - - if (finalSettlement === null) { - return []; - } - - await submitSettlement({ - ...input, - settlementContract: input.settlement, - encodedSettlement: finalSettlement, - }); - - return withdrawals.map((w) => w.token.address); -} - -const setupWithdrawTask: () => void = () => - task("withdraw", "Withdraw funds from the settlement contract") - .addOptionalParam( - "origin", - "Address from which to withdraw. If not specified, it defaults to the first provided account", - ) - .addOptionalParam( - "minValue", - "If specified, sets a minimum USD value required to withdraw the balance of a token.", - "0", - types.string, - ) - .addOptionalParam( - "leftover", - "If specified, withdrawing leaves an amount of each token of USD value specified with this flag.", - "0", - types.string, - ) - .addOptionalParam( - "maxFeePercent", - "If the extra gas needed to include a withdrawal is larger than this percent of the withdrawn amount, the token is not withdrawn.", - 5, - types.float, - ) - .addOptionalParam( - "apiUrl", - "If set, the script contacts the API using the given url. Otherwise, the default prod url for the current network is used", - ) - .addParam("receiver", "The address receiving the withdrawn tokens.") - .addFlag( - "dryRun", - "Just simulate the settlement instead of executing the transaction on the blockchain.", - ) - .addFlag( - "blocknativeGasPrice", - "Use BlockNative gas price estimates for transactions.", - ) - .addOptionalVariadicPositionalParam( - "tokens", - "An optional subset of tokens to consider for withdraw (otherwise all traded tokens will be queried).", - ) - .setAction( - async ( - { - origin, - minValue, - leftover, - maxFeePercent, - receiver: inputReceiver, - dryRun, - tokens, - apiUrl, - blocknativeGasPrice, - }, - hre: HardhatRuntimeEnvironment, - ) => { - const network = hre.network.name; - if (!isSupportedNetwork(network)) { - throw new Error(`Unsupported network ${network}`); - } - const gasEstimator = createGasEstimator(hre, { - blockNative: blocknativeGasPrice, - }); - const api = new Api(network, apiUrl ?? Environment.Prod); - const receiver = utils.getAddress(inputReceiver); - const [authenticator, settlementDeployment, solver] = await Promise.all( - [ - getDeployedContract("GPv2AllowListAuthentication", hre), - hre.deployments.get("GPv2Settlement"), - getSignerOrAddress(hre, origin), - ], - ); - const settlement = new Contract( - settlementDeployment.address, - settlementDeployment.abi, - ).connect(hre.ethers.provider); - const settlementDeploymentBlock = - settlementDeployment.receipt?.blockNumber ?? 0; - console.log(`Using account ${solver.address}`); - - await withdraw({ - solver, - tokens, - minValue, - leftover, - receiver, - maxFeePercent, - authenticator, - settlement, - settlementDeploymentBlock, - network, - usdReference: REFERENCE_TOKEN[network], - hre, - api, - dryRun, - gasEstimator, - }); - }, - ); - -export { setupWithdrawTask }; diff --git a/src/tasks/withdraw/messages.ts b/src/tasks/withdraw/messages.ts deleted file mode 100644 index 104a2b8f..00000000 --- a/src/tasks/withdraw/messages.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BigNumber, utils } from "ethers"; - -import { displayName, Erc20Token, NativeToken } from "../ts/tokens"; -import { formatUsdValue, ReferenceToken } from "../ts/value"; - -export function ignoredTokenMessage( - [token, amount]: [Erc20Token | NativeToken, BigNumber], - reason?: string, - asUsd?: [ReferenceToken, BigNumber], -) { - const decimals = token.decimals ?? 18; - let message = `Ignored ${utils.formatUnits( - amount, - decimals, - )} units of ${displayName(token)}${ - token.decimals === undefined - ? ` (no decimals specified in the contract, assuming ${decimals})` - : "" - }`; - if (asUsd !== undefined) { - const [usdReference, valueUsd] = asUsd; - message += ` with value ${formatUsdValue(valueUsd, usdReference)} USD`; - } - if (reason !== undefined) { - message += `, ${reason}`; - } - return message; -} diff --git a/src/tasks/withdraw/safe.ts b/src/tasks/withdraw/safe.ts deleted file mode 100644 index 300ae5d6..00000000 --- a/src/tasks/withdraw/safe.ts +++ /dev/null @@ -1,81 +0,0 @@ -import SafeApiKit from "@safe-global/api-kit"; -import Safe, { EthersAdapter } from "@safe-global/protocol-kit"; -import { SafeTransactionDataPartial } from "@safe-global/safe-core-sdk-types"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -interface Transaction { - authoringSafe: string; - to: string; - data: string; -} - -function serviceUrlForNetwork(network: string): string { - if (["goerli", "mainnet", "gnosis-chain", "sepolia"].includes(network)) { - return `https://safe-transaction-${network}.safe.global`; - } else if (network === "xdai") { - return "https://safe-transaction-gnosis-chain.safe.global/"; - } else { - throw new Error(`Unsupported network ${network}`); - } -} - -interface SafesAddressNoncesOutput { - recommendedNonce: number; -} -// Once `@safe-global/api-kit` has been migrated from v1 to v2, this can be replaced with `getnextnonce`. -// -async function recommendedNonce(chainId: number, safeAddress: string) { - // - const url = `https://safe-client.safe.global/v1/chains/${chainId.toString()}/safes/${safeAddress}/nonces`; - const response = await fetch(url); - const output: SafesAddressNoncesOutput = await response.json(); - return output.recommendedNonce; -} - -// Creates and proposes a transaction to the Safe Multisig, which can then be confirmed by other signers in the Web UI. Returns the link to the transaction in the Web UI. -export async function proposeTransaction( - { ethers }: HardhatRuntimeEnvironment, - network: string, - { authoringSafe, to, data }: Transaction, -): Promise { - const { chainId } = await ethers.provider.getNetwork(); - const [proposer] = await ethers.getSigners(); - const ethAdapter = new EthersAdapter({ - ethers, - signerOrProvider: proposer, - }); - let nonce; - try { - nonce = await recommendedNonce(chainId, authoringSafe); - } catch { - console.log("Unable to determine recommended nonce, using current one"); - nonce = undefined; - } - - const safeTransactionData: SafeTransactionDataPartial = { - to, - data, - value: "0", - nonce, - }; - - const safeSdk = await Safe.create({ ethAdapter, safeAddress: authoringSafe }); - const safeTransaction = await safeSdk.createTransaction({ - safeTransactionData, - }); - const safeTxHash = await safeSdk.getTransactionHash(safeTransaction); - const senderSignature = await safeSdk.signTransactionHash(safeTxHash); - - const safeService = new SafeApiKit({ - txServiceUrl: serviceUrlForNetwork(network), - ethAdapter, - }); - await safeService.proposeTransaction({ - safeAddress: authoringSafe, - safeTransactionData: safeTransaction.data, - safeTxHash, - senderAddress: await proposer.getAddress(), - senderSignature: senderSignature.data, - }); - return `https://app.safe.global/transactions/tx?id=multisig_${authoringSafe}_${safeTxHash}&safe=${authoringSafe}`; -} diff --git a/src/tasks/withdraw/settle.ts b/src/tasks/withdraw/settle.ts deleted file mode 100644 index 05e6d1d9..00000000 --- a/src/tasks/withdraw/settle.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Contract } from "ethers"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { EncodedSettlement } from "../../ts"; -import { IGasEstimator } from "../ts/gas"; -import { prompt } from "../ts/tui"; - -import { isSigner, SignerOrAddress } from "./signer"; - -export interface SubmitSettlementInput { - dryRun: boolean; - doNotPrompt?: boolean | undefined; - hre: HardhatRuntimeEnvironment; - settlementContract: Contract; - solver: SignerOrAddress; - requiredConfirmations?: number | undefined; - gasEstimator: IGasEstimator; - encodedSettlement: EncodedSettlement; -} -export async function submitSettlement({ - dryRun, - doNotPrompt, - hre, - settlementContract, - solver, - requiredConfirmations, - gasEstimator, - encodedSettlement, -}: SubmitSettlementInput) { - if (!isSigner(solver)) { - const settlementData = settlementContract.interface.encodeFunctionData( - "settle", - encodedSettlement, - ); - console.log("Settlement transaction:"); - console.log(`to: ${settlementContract.address}`); - console.log(`data: ${settlementData}`); - } else if ( - !dryRun && - (doNotPrompt || (await prompt(hre, "Submit settlement?"))) - ) { - console.log("Executing the withdraw transaction on the blockchain..."); - const response = await settlementContract - .connect(solver) - .settle(...encodedSettlement, await gasEstimator.txGasPrice()); - console.log( - "Transaction submitted to the blockchain. Waiting for acceptance in a block...", - ); - const receipt = await response.wait(requiredConfirmations); - console.log( - `Transaction successfully executed. Transaction hash: ${receipt.transactionHash}`, - ); - } -} diff --git a/src/tasks/withdraw/signer.ts b/src/tasks/withdraw/signer.ts deleted file mode 100644 index 03aacc29..00000000 --- a/src/tasks/withdraw/signer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -export type SignerOrAddress = - | SignerWithAddress - | { address: string; _isSigner: false }; - -export async function getSignerOrAddress( - { ethers }: HardhatRuntimeEnvironment, - origin?: string, -): Promise { - const signers = await ethers.getSigners(); - const originAddress = ethers.utils.getAddress(origin ?? signers[0].address); - return ( - signers.find(({ address }) => address === originAddress) ?? { - address: originAddress, - // Take advantage of the fact that all Ethers signers have `_isSigner` set - // to `true`. - _isSigner: false, - } - ); -} - -export function isSigner(solver: SignerOrAddress): solver is SignerWithAddress { - return solver._isSigner; -} diff --git a/src/tasks/withdraw/slack.ts b/src/tasks/withdraw/slack.ts deleted file mode 100644 index 1cc3e371..00000000 --- a/src/tasks/withdraw/slack.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { WebClient } from "@slack/web-api"; - -const { SLACK_TOKEN } = process.env; - -export async function sendSlackMessage( - channel: string, - linkToProposal: string, -): Promise { - if (!SLACK_TOKEN) { - throw Error("SLACK_TOKEN environment variable is not set"); - } - const web = new WebClient(SLACK_TOKEN); - const response = await web.chat.postMessage({ - channel, - text: ` please sign & execute the fee withdrawal transaction: ${linkToProposal}`, - }); - if (!response.ok) { - console.error(`Failed to send slack message: ${response}`); - } -} diff --git a/src/tasks/withdraw/token_balances.ts b/src/tasks/withdraw/token_balances.ts deleted file mode 100644 index 1895ea1d..00000000 --- a/src/tasks/withdraw/token_balances.ts +++ /dev/null @@ -1,99 +0,0 @@ -import axios from "axios"; - -export interface GetTokensInput { - minValueUsd: number; - settlementContract: string; - chainId: number; -} - -export async function getTokensWithBalanceAbove({ - minValueUsd, - settlementContract, - chainId, -}: GetTokensInput): Promise { - switch (chainId) { - case 1: - return await getMainnetTokensWithBalanceAbove( - minValueUsd, - settlementContract, - ); - case 100: - return await getGnosisChainTokensWithBalanceAbove( - minValueUsd, - settlementContract, - ); - default: - throw new Error( - `Automatic token list generation is not supported on chain with id ${chainId}`, - ); - } -} - -interface EthplorerAddressInfoResponse { - tokens: { - tokenInfo: { - address: string; - decimals: number; - price: { - rate: number; - }; - }; - balance: number; - }[]; -} - -export async function getMainnetTokensWithBalanceAbove( - minValueUsd: number, - settlementContract: string, -): Promise { - const response = await axios.get( - `https://api.ethplorer.io/getAddressInfo/${settlementContract}?apiKey=freekey`, - ); - if (response.status !== 200) { - throw new Error(`Error getting tokens from ETHplorer ${response}`); - } - const result = []; - const data = response.data as EthplorerAddressInfoResponse; - for (const token of data.tokens) { - const tokenUsdValue = - token.tokenInfo.price.rate * - (token.balance / Math.pow(10, token.tokenInfo.decimals)); - if (tokenUsdValue > minValueUsd) { - result.push(token.tokenInfo.address); - } - } - return result; -} - -type BlockscoutAddressInfoResponse = BlockscoutSingleTokenInfo[]; -interface BlockscoutSingleTokenInfo { - token: { - address: string; - exchange_rate: string; - decimals: string; - }; - value: string; -} - -export async function getGnosisChainTokensWithBalanceAbove( - minValueUsd: number, - settlementContract: string, -): Promise { - const response = await axios.get( - `https://gnosis.blockscout.com/api/v2/addresses/${settlementContract}/token-balances`, - ); - if (response.status !== 200) { - throw new Error(`Error getting tokens from ETHplorer ${response}`); - } - const result = []; - const data = response.data as BlockscoutAddressInfoResponse; - for (const { value, token } of data) { - const tokenUsdValue = - parseFloat(token.exchange_rate) * - (parseInt(value) / Math.pow(10, parseInt(token.decimals))); - if (tokenUsdValue > minValueUsd) { - result.push(token.address); - } - } - return result; -} diff --git a/src/tasks/withdraw/traded_tokens.ts b/src/tasks/withdraw/traded_tokens.ts deleted file mode 100644 index 454c5d8c..00000000 --- a/src/tasks/withdraw/traded_tokens.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Contract } from "ethers"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { BUY_ETH_ADDRESS } from "../../ts"; - -const enum ProviderError { - TooManyEvents, - Timeout, -} - -export interface TradedTokens { - tokens: string[]; - toBlock: number; -} - -export const partialTradedTokensKey = "partialTradedTokens" as const; -export interface TradedTokensError extends Error { - [partialTradedTokensKey]: TradedTokens; -} - -function decodeError(error: unknown): ProviderError | null { - if (!(error instanceof Error)) { - return null; - } - const message = error.message; - if (/query returned more than \d* results/.test(message)) { - return ProviderError.TooManyEvents; - } - if (/Network connection timed out/.test(message)) { - return ProviderError.Timeout; - } - return null; -} - -/// Lists all tokens that were traded by the settlement contract in the range -/// specified by the two input blocks. Range bounds are both inclusive. -/// The output value `lastFullyIncludedBlock` returns the block numbers of the -/// latest block for which traded tokens were searched. Note that the returned -/// block value might not be exact in some circumstances like reorgs or -/// load-balanced nodes. -/// -/// If an unexpected node error is encountered, the thown error may include the -/// tokens that were processed so far. See [TradedTokensError] for the error -/// format. -export async function getAllTradedTokens( - settlement: Contract, - fromBlock: number, - toBlock: number | "latest", - hre: HardhatRuntimeEnvironment, -): Promise { - let trades = null; - let numericToBlock = - toBlock === "latest" ? hre.ethers.provider.getBlockNumber() : toBlock; - try { - trades = await hre.ethers.provider.getLogs({ - topics: [settlement.interface.getEventTopic("Trade")], - address: settlement.address, - fromBlock, - toBlock, - }); - console.log(`Processed events from block ${fromBlock} to ${toBlock}`); - } catch (error) { - switch (decodeError(error)) { - // If the query is too large, Infura throws "too many events" error while - // other nodes time out. - case ProviderError.Timeout: - case ProviderError.TooManyEvents: - console.log( - `Failed to process events from block ${fromBlock} to ${toBlock}, reducing range...`, - ); - break; - case null: - throw error; - } - } - - let tokens; - if (trades === null) { - if (fromBlock === toBlock) { - throw new Error("Too many events in the same block"); - } - const mid = Math.floor(((await numericToBlock) + fromBlock) / 2); - - const firstHalf = await getAllTradedTokens(settlement, fromBlock, mid, hre); - - let secondHalf: TradedTokens; - try { - secondHalf = await getAllTradedTokens(settlement, mid + 1, toBlock, hre); - } catch (error) { - if (!(error instanceof Error)) { - throw error; - } - if (!Object.keys(error).includes(partialTradedTokensKey)) { - (error as TradedTokensError)[partialTradedTokensKey] = firstHalf; - } else { - const partialSecondHalf = (error as TradedTokensError)[ - partialTradedTokensKey - ]; - const partialTradedTokens: TradedTokens = { - tokens: [...firstHalf.tokens, ...partialSecondHalf.tokens], - toBlock: partialSecondHalf.toBlock, - }; - (error as TradedTokensError)[partialTradedTokensKey] = - partialTradedTokens; - } - throw error; - } - - tokens = [...firstHalf.tokens, ...secondHalf.tokens]; - numericToBlock = secondHalf.toBlock; - } else { - tokens = trades - .map((trade) => { - const decodedTrade = settlement.interface.decodeEventLog( - "Trade", - trade.data, - trade.topics, - ); - return [decodedTrade.sellToken, decodedTrade.buyToken]; - }) - .flat(); - } - - tokens = new Set(tokens); - tokens.delete(BUY_ETH_ADDRESS); - return { - tokens: Array.from(tokens).sort((lhs, rhs) => - lhs.toLowerCase() < rhs.toLowerCase() ? -1 : lhs === rhs ? 0 : 1, - ), - toBlock: await numericToBlock, - }; -} diff --git a/src/tasks/withdrawService.ts b/src/tasks/withdrawService.ts deleted file mode 100644 index 7342c8ef..00000000 --- a/src/tasks/withdrawService.ts +++ /dev/null @@ -1,620 +0,0 @@ -import "@nomiclabs/hardhat-ethers"; - -import { promises as fs, constants as fsConstants } from "fs"; - -import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; -import { assert } from "chai"; -import { utils, Contract } from "ethers"; -import { task, types } from "hardhat/config"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; - -import { BUY_ETH_ADDRESS } from "../ts"; -import { Api, Environment } from "../ts/api"; - -import { - assertNotBuyingNativeAsset, - dump, - MAX_ORDER_VALIDITY_SECONDS, -} from "./dump"; -import { - getDeployedContract, - isSupportedNetwork, - SupportedNetwork, -} from "./ts/deployment"; -import { createGasEstimator, IGasEstimator } from "./ts/gas"; -import { promiseAllWithRateLimit } from "./ts/rate_limits"; -import { balanceOf, erc20Token } from "./ts/tokens"; -import { ReferenceToken, REFERENCE_TOKEN } from "./ts/value"; -import { withdraw } from "./withdraw"; -import { - getAllTradedTokens, - partialTradedTokensKey, - TradedTokens, - TradedTokensError, -} from "./withdraw/traded_tokens"; - -const MAX_ORDER_RETRIES_BEFORE_SKIPPING = 10; -const MAX_CHECKED_TOKENS_PER_RUN = 200; - -export interface State { - /** - * Block number at which the current list of traded tokens was updated. - */ - lastUpdateBlock: number; - /** - * All tokens ever traded on GPv2. Stored in the state to avoid stressing the - * node by recovering the list every time. - */ - tradedTokens: string[]; - /** - * Index (in tradedTokens) following that of the last withdrawn token in the - * previous run of this script. - */ - nextTokenToTrade: number; - /** - * Tokens that have a pending order from a previous execution of the withdraw - * service. - */ - pendingTokens: PendingToken[]; - /** - * Number of consecutive soft errors in a row. A soft error is an error that - * has no irreversible consequences, for example a network timeout before - * any transactions has been sent onchain. - */ - softErrorCount?: number; - /** - * The chain id of the chain that was used to generate this state. The chain - * id is determined on the first run and cannot change. - */ - chainId?: number; -} - -interface PendingToken { - /** - * The address of the token. - */ - address: string; - /** - * How many consecutive times the script sold this token with no success. - * Note that the script will not wait to check if the orders were successful, - * which means that every order in a run will add a pending token with one - * retry. - */ - retries: number; -} - -function isValidState(state: unknown): state is State { - if (state === null || typeof state !== "object") { - console.error("State json is not an object"); - return false; - } - const stateObject = state as State; - if (typeof stateObject["lastUpdateBlock"] !== "number") { - console.error("Invalid lastUpdateBlock"); - return false; - } - if (typeof stateObject["nextTokenToTrade"] !== "number") { - console.error("Invalid nextTokenToTrade"); - return false; - } - if ( - !( - stateObject["tradedTokens"] instanceof Array && - stateObject["tradedTokens"].every( - (elt) => typeof elt === "string" && utils.isAddress(elt), - ) - ) - ) { - console.error("Invalid tradedTokens"); - return false; - } - if ( - !( - stateObject["pendingTokens"] instanceof Array && - stateObject["pendingTokens"].every( - (elt) => - elt !== null && - typeof elt === "object" && - typeof elt.address === "string" && - utils.isAddress(elt.address) && - typeof elt.retries === "number", - ) - ) - ) { - console.error("Invalid pendingTokens"); - return false; - } - return true; -} - -function bumpErrorCount(state: State) { - const softErrorCount = (state.softErrorCount ?? 0) + 1; - // exponential alert backoff - if (Number.isInteger(Math.log2(softErrorCount / 10))) { - console.error(`Encountered ${softErrorCount} soft errors in a row`); - } - return { - ...state, - softErrorCount, - }; -} - -async function updatePendingTokens( - pendingTokens: PendingToken[], - solver: string, - hre: HardhatRuntimeEnvironment, -): Promise { - return ( - await promiseAllWithRateLimit( - pendingTokens.map((pendingToken) => async ({ consoleError }) => { - if (pendingToken.retries >= MAX_ORDER_RETRIES_BEFORE_SKIPPING) { - // Note that this error might be triggered in legitimate cases, for - // example if a token did not trade the first time and then the price - // has become so low that it's not enough to pay for the fee. - // TODO: revisit after getting an idea of the frequency at which this - // alert is triggered. - consoleError( - `Tried ${pendingToken.retries} times to sell token ${pendingToken.address} without success. Skipping token until future run`, - ); - return []; - } - - // assumption: eth is not in the list (as it's not supported by the - // withdraw script). This should not happen unless the pending token - // list in the state was manually changed and ETH was added. - assert( - pendingToken.address.toLowerCase() !== BUY_ETH_ADDRESS.toLowerCase(), - "Pending tokens should not contain ETH", - ); - const token = await erc20Token(pendingToken.address, hre); - if (token === null) { - throw new Error( - `Previously sold a token that is not a valid ERC20 token anymore (address ${pendingToken.address})`, - ); - } - return (await balanceOf(token, solver)).isZero() ? [] : [pendingToken]; - }), - ) - ).flat(); -} - -function bumpAllPendingTokenRetries( - pendingTokens: PendingToken[], -): PendingToken[] { - return pendingTokens.map((pendingToken) => ({ - ...pendingToken, - retries: pendingToken.retries + 1, - })); -} - -export interface WithdrawAndDumpInput { - state: State; - solver: SignerWithAddress; - receiver: string; - authenticator: Contract; - settlement: Contract; - settlementDeploymentBlock: number; - minValue: string; - leftover: string; - validity: number; - maxFeePercent: number; - slippageBps: number; - toToken: string; - network: SupportedNetwork; - usdReference: ReferenceToken; - hre: HardhatRuntimeEnvironment; - api: Api; - dryRun: boolean; - gasEstimator: IGasEstimator; - confirmationsAfterWithdrawing?: number | undefined; - pagination?: number | undefined; -} -/** - * This function withdraws funds from the settlement contracts and puts the - * withdrawn amount up for trade in GPv2 in exchange for a target token. The - * proceeds are sent to a receiver address. - * - * This function is supposed to be called regularly and only a portion of the - * possible withdraws are performed at a time. This is done in order not to - * stress the infrastructure with too many simultaneous requests (node calls, - * api queries, orders). - * Because of this, the withdraw processing works based on a state containing - * information on which tokens should be withdrawn next. The output of this - * function is the updated state. - * - * The process comprises two steps: - * 1. funds are withdrawn from the settlement contract to the solver - * 2. orders are signed by the solver to sell these funds - * This function inherit some of the parameters used in each intermediate step. - */ -export async function withdrawAndDump({ - state, - solver, - receiver, - authenticator, - settlement, - settlementDeploymentBlock, - minValue, - leftover, - validity, - maxFeePercent, - slippageBps, - toToken, - network, - usdReference, - hre, - api, - dryRun, - gasEstimator, - confirmationsAfterWithdrawing, - pagination, -}: WithdrawAndDumpInput): Promise { - let chainId; - try { - ({ chainId } = await hre.ethers.provider.getNetwork()); - } catch (error) { - console.log( - "Soft error encountered when retrieving information from the node", - ); - console.log(error); - return bumpErrorCount(state); - } - - if (state.chainId === undefined) { - state.chainId = chainId; - } else if (state.chainId !== chainId) { - throw new Error( - `Current state file was created on chain id ${state.chainId}, current chain id is ${chainId}.`, - ); - } - - if (pagination === undefined) { - pagination = MAX_CHECKED_TOKENS_PER_RUN; - } else if (pagination > MAX_CHECKED_TOKENS_PER_RUN) { - throw new Error( - `Too many tokens checked per run (${pagination}, max ${MAX_CHECKED_TOKENS_PER_RUN})`, - ); - } - const stateUpdates: Partial = {}; - - // Update list of pending tokens to determine which token was traded - let pendingTokens; - try { - pendingTokens = await updatePendingTokens( - state.pendingTokens, - solver.address, - hre, - ); - } catch (error) { - console.log(`Encountered soft error when updating pending token list`); - console.log(error); - return bumpErrorCount({ ...state, ...stateUpdates }); - } - stateUpdates.pendingTokens = pendingTokens; - - console.log("Recovering list of tokens traded since the previous run..."); - // Add extra blocks before the last update in case there was a reorg and new - // transactions were included. - const maxReorgDistance = 20; - const fromBlock = Math.max(0, state.lastUpdateBlock - maxReorgDistance); - let tradedTokensWithBlock: TradedTokens; - let tokenRecoveryFailed = false; - try { - tradedTokensWithBlock = await getAllTradedTokens( - settlement, - fromBlock, - "latest", - hre, - ); - } catch (error) { - console.log(`Encountered soft error when retrieving traded tokens`); - if ( - error instanceof Error && - Object.keys(error).includes(partialTradedTokensKey) - ) { - tokenRecoveryFailed = true; - tradedTokensWithBlock = (error as TradedTokensError)[ - partialTradedTokensKey - ]; - delete (error as Error & Record)[partialTradedTokensKey]; - console.log(error); - } else { - console.log(error); - return bumpErrorCount({ ...state, ...stateUpdates }); - } - } - - const tradedTokens = state.tradedTokens.concat( - tradedTokensWithBlock.tokens.filter( - (token) => - !(state.tradedTokens.includes(token) || token === BUY_ETH_ADDRESS), - ), - ); - stateUpdates.tradedTokens = tradedTokens; - stateUpdates.lastUpdateBlock = tradedTokensWithBlock.toBlock; - - if (tokenRecoveryFailed) { - return bumpErrorCount({ ...state, ...stateUpdates }); - } - - const numCheckedTokens = Math.min(pagination, tradedTokens.length); - // The index of the checked token wraps around after reaching the end of the - // traded token list - const checkedTokens = tradedTokens - .concat(tradedTokens) - .slice(state.nextTokenToTrade, state.nextTokenToTrade + numCheckedTokens); - - console.log("Starting withdraw step..."); - const withdrawnTokens = await withdraw({ - solver, - tokens: checkedTokens, - minValue, - leftover, - maxFeePercent, - receiver: solver.address, - authenticator, - settlement, - settlementDeploymentBlock, - network, - usdReference, - hre, - api, - dryRun, - gasEstimator, - doNotPrompt: true, - // Wait for node to pick up updated balances before running the dump - // function - requiredConfirmations: confirmationsAfterWithdrawing, - }); - - if (withdrawnTokens === null) { - console.log(`Encountered soft error during withdraw step`); - return bumpErrorCount({ ...state, ...stateUpdates }); - } - - stateUpdates.nextTokenToTrade = - (state.nextTokenToTrade + numCheckedTokens) % tradedTokens.length; - - const tokensToDump = Array.from( - new Set(pendingTokens.map((t) => t.address).concat(withdrawnTokens)), - ).filter((addr) => addr !== BUY_ETH_ADDRESS); - - await dump({ - validity, - maxFeePercent, - slippageBps, - dumpedTokens: tokensToDump, - toToken, - settlement, - signer: solver, - receiver, - network, - hre, - api, - dryRun, - gasEstimator, - doNotPrompt: true, - }); - if (dryRun) { - console.log( - "Dry run: the amount withdrawn from the settlement contract are missing, only amounts that are already present at this address are shown.", - ); - } - - const updatedPendingTokens = bumpAllPendingTokenRetries( - stateUpdates.pendingTokens, - ); - updatedPendingTokens.push( - ...tokensToDump - .filter( - (address) => - !updatedPendingTokens - .map((pendingToken) => pendingToken.address) - .includes(address), - ) - .map((address) => ({ address, retries: 1 })), - ); - stateUpdates.pendingTokens = updatedPendingTokens; - stateUpdates.softErrorCount = 0; - - // stateUpdates is now populated with everything needed to be a proper state. - // The type system isn't able to see that however, the second best thing to - // verify that everything is set is a runtime check and tests. - if (!isValidState(stateUpdates)) { - console.log("Generated state:", stateUpdates); - throw new Error("Withdraw service did not generate a valid state"); - } - return stateUpdates; -} - -async function fileExists(path: string): Promise { - try { - await fs.access(path, fsConstants.F_OK); - return true; - } catch (e) { - return false; - } -} - -async function getState(stateFilePath: string): Promise { - let state: State; - if (!(await fileExists(stateFilePath))) { - console.debug("No state found, using empty state"); - state = { - lastUpdateBlock: 0, - tradedTokens: [], - nextTokenToTrade: 0, - pendingTokens: [], - }; - } else { - console.debug(`Loading state from ${stateFilePath}...`); - state = JSON.parse((await fs.readFile(stateFilePath)).toString()); - } - if (!isValidState(state)) { - console.error(`Bad initial state: ${JSON.stringify(state)}`); - throw new Error("Invalid state detect"); - } - return state; -} - -async function updateStateOnDisk( - updatedState: State, - stateFilePath: string, - dryRun: boolean, -) { - console.debug(`Updated state: ${JSON.stringify(updatedState)}`); - if (!dryRun) { - await fs.writeFile( - stateFilePath, - JSON.stringify(updatedState, undefined, 2), - ); - } -} - -const setupWithdrawServiceTask: () => void = () => - task("withdrawService", "Withdraw funds from the settlement contract") - .addOptionalParam( - "minValue", - "If specified, sets a minimum USD value required to withdraw the balance of a token", - "100", - types.string, - ) - .addOptionalParam( - "leftover", - "If specified, withdrawing leaves an amount of each token of USD value specified with this flag", - "100", - types.string, - ) - .addOptionalParam( - "toToken", - "All input tokens will be dumped to this token. If not specified, it defaults to the network's native token (e.g., ETH)", - ) - .addOptionalParam( - "validity", - `How long the sell orders will be valid after their creation in seconds. It cannot be larger than ${MAX_ORDER_VALIDITY_SECONDS}`, - 20 * 60, - types.int, - ) - .addOptionalParam( - "maxFeePercent", - "If, for any token, the amount of fee to be paid is larger than this percent of the traded amount, that token is not traded", - 5, - types.float, - ) - .addOptionalParam( - "slippageBps", - "The slippage in basis points for selling the dumped tokens", - 10, - types.int, - ) - .addOptionalParam( - "stateFilePath", - "The path to the file where the state of the script is stored. This file will be updated after a run", - "./state.json", - types.string, - ) - .addOptionalParam( - "tokensPerRun", - `The maximum number of tokens to process in a single withdraw run. Must be smaller than ${MAX_CHECKED_TOKENS_PER_RUN}`, - ) - .addOptionalParam( - "apiUrl", - "If set, the script contacts the API using the given url. Otherwise, the default prod url for the current network is used", - ) - .addParam("receiver", "The address receiving the withdrawn tokens") - .addFlag( - "dryRun", - "Just simulate the settlement instead of executing the transaction on the blockchain", - ) - .addFlag( - "blocknativeGasPrice", - "Use BlockNative gas price estimates for transactions.", - ) - .setAction( - async ( - { - minValue, - leftover, - toToken, - validity, - maxFeePercent, - slippageBps, - stateFilePath, - receiver: inputReceiver, - dryRun, - tokensPerRun, - apiUrl, - blocknativeGasPrice, - }, - hre: HardhatRuntimeEnvironment, - ) => { - // TODO: remove once native asset orders are fully supported. - assertNotBuyingNativeAsset(toToken); - - const state = await getState(stateFilePath); - console.debug(`Initial state: ${JSON.stringify(state)}`); - const network = hre.network.name; - if (!isSupportedNetwork(network)) { - throw new Error(`Unsupported network ${network}`); - } - const gasEstimator = createGasEstimator(hre, { - blockNative: blocknativeGasPrice, - }); - const usdReference = REFERENCE_TOKEN[network]; - const api = new Api(network, apiUrl ?? Environment.Prod); - const receiver = utils.getAddress(inputReceiver); - let authenticator, settlementDeployment, solver; - try { - [authenticator, settlementDeployment, [solver]] = await Promise.all([ - getDeployedContract("GPv2AllowListAuthentication", hre), - hre.deployments.get("GPv2Settlement"), - hre.ethers.getSigners(), - ]); - } catch (error) { - console.log( - "Soft error encountered when retrieving information from the node", - ); - console.log(error); - const updatedState = { - ...state, - softErrorCount: (state.softErrorCount ?? 0) + 1, - }; - await updateStateOnDisk(updatedState, stateFilePath, dryRun); - return; - } - const settlement = new Contract( - settlementDeployment.address, - settlementDeployment.abi, - ).connect(hre.ethers.provider); - const settlementDeploymentBlock = - settlementDeployment.receipt?.blockNumber ?? 0; - console.log(`Using account ${solver.address}`); - - const updatedState = await withdrawAndDump({ - state, - solver, - receiver, - authenticator, - settlement, - settlementDeploymentBlock, - minValue, - leftover, - validity, - maxFeePercent, - slippageBps, - toToken, - network, - usdReference, - hre, - api, - dryRun, - gasEstimator, - confirmationsAfterWithdrawing: 2, - pagination: tokensPerRun, - }); - - await updateStateOnDisk(updatedState, stateFilePath, dryRun); - }, - ); - -export { setupWithdrawServiceTask }; diff --git a/test/tasks/dump.test.ts b/test/tasks/dump.test.ts deleted file mode 100644 index a529ad8b..00000000 --- a/test/tasks/dump.test.ts +++ /dev/null @@ -1,1000 +0,0 @@ -import { MockContract } from "@ethereum-waffle/mock-contract"; -import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; -import chai, { expect } from "chai"; -import chaiAsPromised from "chai-as-promised"; -import { - BigNumber, - BigNumberish, - constants, - Contract, - utils, - Wallet, -} from "ethers"; -import hre, { ethers, waffle } from "hardhat"; -import { match, mock, SinonMock } from "sinon"; - -import { - APP_DATA, - dump, - GetDumpInstructionInput, - getDumpInstructions, -} from "../../src/tasks/dump"; -import { ProviderGasEstimator } from "../../src/tasks/ts/gas"; -import { Erc20Token, isNativeToken } from "../../src/tasks/ts/tokens"; -import { BUY_ETH_ADDRESS, OrderKind } from "../../src/ts"; -import { - Api, - CallError, - Environment, - GetQuoteErrorType, - PlaceOrderQuery, -} from "../../src/ts/api"; -import { deployTestContracts } from "../e2e/fixture"; -import { synchronizeBlockchainAndCurrentTime } from "../hardhatNetwork"; - -import { restoreStandardConsole, useDebugConsole } from "./logging"; - -chai.use(chaiAsPromised); - -const IERC20 = hre.artifacts.readArtifact( - "src/contracts/interfaces/IERC20.sol:IERC20", -); -async function mockErc20(deployer: Wallet) { - return waffle.deployMockContract(deployer, (await IERC20).abi); -} - -interface MockApiCallsInput { - apiMock: SinonMock; - toToken: string; - dumpedToken: string; - balance: BigNumberish; - fee: BigNumberish; - boughtAmount: BigNumberish; - from: string; -} -function mockApiCalls({ - apiMock, - toToken, - dumpedToken, - balance, - fee, - boughtAmount, - from, -}: MockApiCallsInput): void { - const result = { - quote: { - feeAmount: BigNumber.from(fee), - buyAmount: BigNumber.from(boughtAmount), - sellAmount: BigNumber.from(balance).sub(fee), - }, - }; - apiMock - .expects("getQuote") - .withArgs({ - sellToken: dumpedToken, - buyToken: toToken, - validTo: match.any, - appData: APP_DATA, - partiallyFillable: false, - from, - kind: OrderKind.SELL, - sellAmountBeforeFee: balance, - }) - .atLeast(1) - .returns(Promise.resolve(result)); -} - -interface MockQuerySellingTokenForEthInput { - apiMock: SinonMock; - amount: BigNumber; - token: string; - ethValue: BigNumber; -} -export function mockQuerySellingTokenForEth({ - apiMock, - amount, - token, - ethValue, -}: MockQuerySellingTokenForEthInput): void { - apiMock - .expects("estimateTradeAmount") - .withArgs({ - sellToken: token, - buyToken: BUY_ETH_ADDRESS, - kind: OrderKind.SELL, - amount, - }) - .once() - .returns(Promise.resolve(ethValue)); -} - -// Even if internally handled in the mocking code, some (successful) tests throw -// a warning "Promise rejection was handled asynchronously". This function -// returns a pre-handled rejection to suppress that warning. -// https://github.com/domenic/chai-as-promised/issues/173 -async function handledRejection(error?: unknown) { - const rejection = Promise.reject(error); - await rejection.catch(() => { - /* ignored */ - }); - return { rejection }; -} - -// Any network works for testing. -const network = "mainnet"; - -describe("getDumpInstructions", () => { - let consoleLogOutput: unknown = undefined; - let consoleLog: typeof console.log; - const vaultRelayer = "0xa11044a9ce" + "42".repeat(20 - 5); - - let deployer: Wallet; - let user: Wallet; - let receiver: Wallet; - let apiMock: SinonMock; - let api: Api; - - let defaultDumpInstructions: Omit< - GetDumpInstructionInput, - "dumpedTokens" | "toTokenAddress" - >; - - beforeEach(async () => { - consoleLog = console.log; - console.log = (...args: unknown[]) => (consoleLogOutput = args[0]); - - [deployer, user, receiver] = waffle.provider.getWallets(); - // environment parameter is unused in mock - const environment = "unset environment" as unknown as Environment; - api = new Api("mock", environment); - apiMock = mock(api); - - defaultDumpInstructions = { - user: user.address, - vaultRelayer: vaultRelayer, - maxFeePercent: Infinity, - slippageBps: 0, - receiver: { - address: receiver.address, - isSameAsUser: true, - }, - validity: 30 * 60, - hre, - network, - api, - gasEstimator: new ProviderGasEstimator(ethers.provider), - }; - }); - - afterEach(function () { - if (this.currentTest?.isPassed()) { - apiMock.verify(); - } - console.log = consoleLog; - consoleLogOutput = undefined; - }); - - it("dumps token for token", async () => { - const to = await mockErc20(deployer); - await to.mock.symbol.returns("TOTOKEN"); - await to.mock.decimals.returns(101); - const dumped = await mockErc20(deployer); - await dumped.mock.symbol.returns("DUMPEDTOKEN"); - await dumped.mock.decimals.returns(0xd); - - const balance = utils.parseEther("42"); - const fee = utils.parseEther("1"); - const allowance = utils.parseEther("31337"); - const boughtAmount = utils.parseEther("0.1337"); - - await dumped.mock.balanceOf.withArgs(user.address).returns(balance); - await dumped.mock.allowance - .withArgs(user.address, vaultRelayer) - .returns(allowance); - mockApiCalls({ - apiMock, - toToken: to.address, - dumpedToken: dumped.address, - balance, - fee, - boughtAmount, - from: defaultDumpInstructions.user, - }); - - const { toToken, transferToReceiver, instructions } = - await getDumpInstructions({ - ...defaultDumpInstructions, - dumpedTokens: [dumped.address], - toTokenAddress: to.address, - }); - - expect(toToken.symbol).to.deep.equal("TOTOKEN"); - expect(toToken.decimals).to.deep.equal(101); - expect(isNativeToken(toToken)).to.be.false; - expect((toToken as Erc20Token).address).to.deep.equal(to.address); - - expect(transferToReceiver).to.be.undefined; - - expect(instructions).to.have.length(1); - const { - token: dumpedToken, - quote: { - sellAmount: amountWithoutFee, - buyAmount: receivedAmount, - feeAmount: returnedFee, - }, - needsAllowance, - balance: returnedBalance, - } = instructions[0]; - expect(dumpedToken.symbol).to.deep.equal("DUMPEDTOKEN"); - expect(dumpedToken.decimals).to.deep.equal(0xd); - expect(dumpedToken.address).to.deep.equal(dumped.address); - expect(needsAllowance).to.deep.equal(false); - expect(receivedAmount).to.deep.equal(boughtAmount); - expect(amountWithoutFee).to.deep.equal(balance.sub(fee)); - expect(returnedBalance).to.deep.equal(balance); - expect(returnedFee).to.deep.equal(fee); - }); - - for (const to of [undefined, BUY_ETH_ADDRESS]) { - it(`dumps token for eth (${ - to === undefined ? "leaving the toToken parameter undefined" : to - })`, async () => { - const dumped = await mockErc20(deployer); - await dumped.mock.symbol.returns("DUMPEDTOKEN"); - await dumped.mock.decimals.returns(0xd); - - const balance = utils.parseEther("42"); - const fee = utils.parseEther("1"); - const allowance = utils.parseEther("31337"); - const boughtAmount = utils.parseEther("0.1337"); - - await dumped.mock.balanceOf.withArgs(user.address).returns(balance); - await dumped.mock.allowance - .withArgs(user.address, vaultRelayer) - .returns(allowance); - mockApiCalls({ - apiMock, - toToken: BUY_ETH_ADDRESS, - dumpedToken: dumped.address, - balance, - fee, - boughtAmount, - from: defaultDumpInstructions.user, - }); - - const { toToken, transferToReceiver, instructions } = - await getDumpInstructions({ - ...defaultDumpInstructions, - dumpedTokens: [dumped.address], - toTokenAddress: to, - }); - - expect(toToken.symbol).to.deep.equal("ETH"); - expect(toToken.decimals).to.deep.equal(18); - expect(isNativeToken(toToken)).to.be.true; - - expect(transferToReceiver).to.be.undefined; - - expect(instructions).to.have.length(1); - const { - token: dumpedToken, - quote: { - sellAmount: amountWithoutFee, - buyAmount: receivedAmount, - feeAmount: returnedFee, - }, - needsAllowance, - balance: returnedBalance, - } = instructions[0]; - expect(dumpedToken.symbol).to.deep.equal("DUMPEDTOKEN"); - expect(dumpedToken.decimals).to.deep.equal(0xd); - expect(dumpedToken.address).to.deep.equal(dumped.address); - expect(needsAllowance).to.deep.equal(false); - expect(receivedAmount).to.deep.equal(boughtAmount); - expect(amountWithoutFee).to.deep.equal(balance.sub(fee)); - expect(returnedBalance).to.deep.equal(balance); - expect(returnedFee).to.deep.equal(fee); - }); - } - - it("detects that an allowance is needed", async () => { - const to = await mockErc20(deployer); - const dumped = await mockErc20(deployer); - - const balance = utils.parseEther("42"); - const fee = utils.parseEther("1"); - const allowance = balance.sub(1); - const boughtAmount = utils.parseEther("0.1337"); - - await dumped.mock.balanceOf.withArgs(user.address).returns(balance); - await dumped.mock.allowance - .withArgs(user.address, vaultRelayer) - .returns(allowance); - mockApiCalls({ - apiMock, - toToken: to.address, - dumpedToken: dumped.address, - balance, - fee, - boughtAmount, - from: defaultDumpInstructions.user, - }); - - const { instructions } = await getDumpInstructions({ - ...defaultDumpInstructions, - dumpedTokens: [dumped.address], - toTokenAddress: to.address, - }); - - expect(instructions).to.have.length(1); - const { needsAllowance } = instructions[0]; - expect(needsAllowance).to.deep.equal(true); - }); - - it("ignores toToken if it's dumped", async () => { - const to = await mockErc20(deployer); - await to.mock.symbol.returns("TOTOKEN"); - await to.mock.decimals.returns(101); - - const { toToken, transferToReceiver, instructions } = - await getDumpInstructions({ - ...defaultDumpInstructions, - receiver: { - address: receiver.address, - isSameAsUser: true, - }, - dumpedTokens: [to.address], - toTokenAddress: to.address, - }); - - expect(toToken.symbol).to.deep.equal("TOTOKEN"); - expect(toToken.decimals).to.deep.equal(101); - expect(isNativeToken(toToken)).to.be.false; - expect((toToken as Erc20Token).address).to.deep.equal(to.address); - - expect(instructions).to.have.length(0); - - expect(transferToReceiver).to.be.undefined; - }); - - it("transfers toToken if it's dumped and there is a custom receiver", async () => { - const to = await mockErc20(deployer); - await to.mock.symbol.returns("TOTOKEN"); - await to.mock.decimals.returns(101); - - const balance = utils.parseEther("4.2"); - await to.mock.balanceOf.withArgs(user.address).returns(balance); - // script estimates gas usage of a transfer - await to.mock.transfer.withArgs(receiver.address, balance).returns(true); - - mockQuerySellingTokenForEth({ - apiMock, - amount: balance, - token: to.address, - ethValue: BigNumber.from("1"), // default maxFeePercent is infinity, anything nonzero works - }); - - const { toToken, transferToReceiver, instructions } = - await getDumpInstructions({ - ...defaultDumpInstructions, - receiver: { - address: receiver.address, - isSameAsUser: false, - }, - dumpedTokens: [to.address], - toTokenAddress: to.address, - }); - - expect(toToken.symbol).to.deep.equal("TOTOKEN"); - expect(toToken.decimals).to.deep.equal(101); - expect(isNativeToken(toToken)).to.be.false; - expect((toToken as Erc20Token).address).to.deep.equal(to.address); - - expect(instructions).to.have.length(0); - - expect(transferToReceiver).not.to.be.undefined; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { amount, token } = transferToReceiver!; - expect(amount).to.deep.equal(balance); - expect(token).to.deep.equal(toToken); - }); - - it("throws if trying to dump ETH", async () => { - await expect( - getDumpInstructions({ - ...defaultDumpInstructions, - receiver: { - address: receiver.address, - isSameAsUser: false, - }, - dumpedTokens: [BUY_ETH_ADDRESS], - toTokenAddress: constants.AddressZero, - }), - ).to.eventually.be.rejectedWith( - `Dumping the native token is not supported. Remove the ETH flag address ${BUY_ETH_ADDRESS} from the list of tokens to dump.`, - ); - }); - - it("throws if api returns generic error when querying for quote", async () => { - const to = await mockErc20(deployer); - const dumped = await mockErc20(deployer); - - const balance = utils.parseEther("42"); - const allowance = utils.parseEther("31337"); - - await dumped.mock.balanceOf.withArgs(user.address).returns(balance); - await dumped.mock.allowance - .withArgs(user.address, vaultRelayer) - .returns(allowance); - apiMock - .expects("getQuote") - .withArgs({ - sellToken: dumped.address, - buyToken: to.address, - validTo: match.any, - appData: APP_DATA, - partiallyFillable: false, - from: defaultDumpInstructions.user, - kind: OrderKind.SELL, - sellAmountBeforeFee: balance, - }) - .once() - .returns((await handledRejection()).rejection); - - await expect( - getDumpInstructions({ - ...defaultDumpInstructions, - dumpedTokens: [dumped.address], - toTokenAddress: to.address, - }), - ).to.eventually.be.rejected; - }); - - it("does not trade if fee is larger than balance", async () => { - const to = await mockErc20(deployer); - const dumped = await mockErc20(deployer); - - const balance = utils.parseEther("42"); - const allowance = utils.parseEther("31337"); - - await dumped.mock.balanceOf.withArgs(user.address).returns(balance); - await dumped.mock.allowance - .withArgs(user.address, vaultRelayer) - .returns(allowance); - const e: CallError = new Error("Test error"); - e.apiError = { - errorType: GetQuoteErrorType.SellAmountDoesNotCoverFee, - description: "unused", - }; - apiMock - .expects("getQuote") - .withArgs({ - sellToken: dumped.address, - buyToken: to.address, - validTo: match.any, - appData: APP_DATA, - partiallyFillable: false, - from: defaultDumpInstructions.user, - kind: OrderKind.SELL, - sellAmountBeforeFee: balance, - }) - .once() - .returns((await handledRejection(e)).rejection); - - const { transferToReceiver, instructions } = await getDumpInstructions({ - ...defaultDumpInstructions, - dumpedTokens: [dumped.address], - toTokenAddress: to.address, - }); - - expect(transferToReceiver).to.be.undefined; - expect(instructions).to.have.length(0); - - expect(consoleLogOutput).to.equal( - `Ignored 42.0 units of ${dumped.address}, the trading fee is larger than the dumped amount.`, - ); - }); - - it("does not trade if fee is larger than percentage limit", async () => { - const to = await mockErc20(deployer); - const dumped = await mockErc20(deployer); - - const balance = utils.parseEther("42"); - const maxFeePercent = 50; - // note: the check doesn't need to be exact and is approximated using - // floats. This is why here we don't simply add 1 - const fee = utils.parseEther("21.00001"); - const allowance = utils.parseEther("31337"); - - await dumped.mock.balanceOf.withArgs(user.address).returns(balance); - await dumped.mock.allowance - .withArgs(user.address, vaultRelayer) - .returns(allowance); - const result = { - quote: { - feeAmount: BigNumber.from(fee), - buyAmount: BigNumber.from(1337), - sellAmount: balance.sub(fee), - }, - }; - apiMock - .expects("getQuote") - .withArgs({ - sellToken: dumped.address, - buyToken: to.address, - validTo: match.any, - appData: APP_DATA, - partiallyFillable: false, - from: defaultDumpInstructions.user, - kind: OrderKind.SELL, - sellAmountBeforeFee: balance, - }) - .once() - .returns(Promise.resolve(result)); - - const { transferToReceiver, instructions } = await getDumpInstructions({ - ...defaultDumpInstructions, - maxFeePercent, - dumpedTokens: [dumped.address], - toTokenAddress: to.address, - }); - - expect(transferToReceiver).to.be.undefined; - expect(instructions).to.have.length(0); - - expect(consoleLogOutput).to.match( - new RegExp( - `Ignored 42.0 units of ${dumped.address}, the trading fee is too large compared to the balance \\(5[0-9.]+%\\)\\.`, - ), - ); - }); - - it("does not transfer toToken if the balance is zero", async () => { - const to = await mockErc20(deployer); - await to.mock.symbol.returns("TOTOKEN"); - await to.mock.decimals.returns(101); - await to.mock.balanceOf.withArgs(user.address).returns(constants.Zero); - - const { transferToReceiver } = await getDumpInstructions({ - ...defaultDumpInstructions, - receiver: { - address: receiver.address, - isSameAsUser: false, - }, - dumpedTokens: [to.address], - toTokenAddress: to.address, - }); - - expect(transferToReceiver).to.be.undefined; - }); - - it("does not transfer toToken if the transfer fee is too high", async () => { - const symbol = "TOTOKEN"; - const decimals = 18; - async function setupMockToToken(balance: BigNumber): Promise { - const token = await mockErc20(deployer); - await token.mock.symbol.returns(symbol); - await token.mock.decimals.returns(decimals); - await token.mock.balanceOf.withArgs(user.address).returns(balance); - await token.mock.transfer - .withArgs(receiver.address, balance) - .returns(true); - return token; - } - async function estimateTransferGasCost(): Promise { - // assumptions: any deployed mock token has approximatively the same - // transfer cost. The transferred balance doesn't impact much the result. - // The number is close to 10¹⁸ but not divisible by 2. - const anyBalance = BigNumber.from(3).pow(32); - const mock = await setupMockToToken(anyBalance); - return ( - await mock.estimateGas["transfer"](receiver.address, anyBalance) - ).mul(await hre.ethers.provider.getGasPrice()); - } - const maxFeePercent = 5; - const toTokensForOneEth = 42; - const transferCost = await estimateTransferGasCost(); - const minimumEthValueToTransfer = transferCost.mul(100).div(maxFeePercent); - const minimumToTokenBalance = - minimumEthValueToTransfer.mul(toTokensForOneEth); - // use half the minimum amount to account for inprecisions in computing - // the transfer cost - const balance = minimumToTokenBalance.div(2); - expect(minimumToTokenBalance).not.to.deep.equal(constants.Zero); - const to = await setupMockToToken(balance); - - // ten-to-one value with eth - mockQuerySellingTokenForEth({ - apiMock, - amount: balance, - token: to.address, - ethValue: balance.div(toTokensForOneEth), - }); - - const { transferToReceiver } = await getDumpInstructions({ - ...defaultDumpInstructions, - receiver: { - address: receiver.address, - isSameAsUser: false, - }, - maxFeePercent, - dumpedTokens: [to.address], - toTokenAddress: to.address, - }); - - expect(transferToReceiver).to.be.undefined; - }); - - it("works with many tokens", async () => { - const to = await mockErc20(deployer); - await to.mock.symbol.returns("TOTOKEN"); - await to.mock.decimals.returns(101); - const toTokenBalance = utils.parseEther("4.2"); - await to.mock.balanceOf.withArgs(user.address).returns(toTokenBalance); - - interface TokenConfig { - balance: BigNumber; - fee: BigNumber; - allowance: BigNumber; - boughtAmount: BigNumber; - symbol: string; - decimals: number; - index: number; - } - const isIndexWithTooLargeFee = (index: number) => index % 5 === 1; - const isIndexWithoutAllowance = (index: number) => index % 7 === 2; - const pseudorandomTokenConfigs: TokenConfig[] = Array.from( - utils.arrayify(utils.keccak256("0xbaadc0de")), - ).map((byte, index) => { - const balance = constants.WeiPerEther.mul(byte + 1); - return { - index, - balance, - fee: isIndexWithTooLargeFee(index) ? balance.add(1) : balance.div(100), - allowance: isIndexWithoutAllowance(index) - ? balance.sub(1) - : balance.add(index), - // note: adding the index guarantees that all amounts are different. - // This is important since we are checking if the output has the correct - // order by sorting by this value, and equality might cause the sorting - // order to change. - boughtAmount: constants.WeiPerEther.mul(1009 % (byte + 1)).add(index), - decimals: (byte + index) % 30, - symbol: `DUMPED${index}`, - }; - }); - expect(pseudorandomTokenConfigs).to.have.length(32); - - const dumpedTokens: MockContract[] = []; - for (const { - balance, - fee, - allowance, - boughtAmount, - symbol, - decimals, - index, - } of pseudorandomTokenConfigs) { - const dumped = await mockErc20(deployer); - await dumped.mock.symbol.returns(symbol); - await dumped.mock.decimals.returns(decimals); - await dumped.mock.balanceOf.withArgs(user.address).returns(balance); - await dumped.mock.allowance - .withArgs(user.address, vaultRelayer) - .returns(allowance); - let apiReturnValue; - if (isIndexWithTooLargeFee(index)) { - const e: CallError = new Error("Test error"); - e.apiError = { - errorType: GetQuoteErrorType.SellAmountDoesNotCoverFee, - description: "unused", - }; - apiReturnValue = (await handledRejection(e)).rejection; - } else { - const result = { - quote: { - feeAmount: fee, - buyAmount: boughtAmount, - sellAmount: balance.sub(fee), - }, - }; - apiReturnValue = Promise.resolve(result); - } - apiMock - .expects("getQuote") - .withArgs({ - sellToken: dumped.address, - buyToken: to.address, - validTo: match.any, - appData: APP_DATA, - partiallyFillable: false, - from: defaultDumpInstructions.user, - kind: OrderKind.SELL, - sellAmountBeforeFee: balance, - }) - .once() - .returns(apiReturnValue); - dumpedTokens.push(dumped); - } - - // mocks needed to estimate transfer cost - await to.mock.transfer - .withArgs(receiver.address, toTokenBalance) - .returns(true); - mockQuerySellingTokenForEth({ - apiMock, - amount: toTokenBalance, - token: to.address, - ethValue: BigNumber.from("1"), // default maxFeePercent is infinity, anything nonzero works - }); - - const { toToken, transferToReceiver, instructions } = - await getDumpInstructions({ - ...defaultDumpInstructions, - receiver: { - address: receiver.address, - isSameAsUser: false, - }, - dumpedTokens: dumpedTokens.map((t) => t.address).concat(to.address), - toTokenAddress: to.address, - }); - - expect(toToken.symbol).to.deep.equal("TOTOKEN"); - expect(toToken.decimals).to.deep.equal(101); - expect(isNativeToken(toToken)).to.be.false; - expect((toToken as Erc20Token).address).to.deep.equal(to.address); - - expect(transferToReceiver).not.to.be.undefined; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { amount, token } = transferToReceiver!; - expect(amount).to.deep.equal(toTokenBalance); - expect(token).to.deep.equal(toToken); - - const successfulConfigs = pseudorandomTokenConfigs.filter( - (_, i) => !isIndexWithTooLargeFee(i), - ); - expect(instructions) - .to.have.length(successfulConfigs.length) - .and.to.be.greaterThan(10); - successfulConfigs.sort((lhs, rhs) => - lhs.boughtAmount.eq(rhs.boughtAmount) - ? 0 - : lhs.boughtAmount.lt(rhs.boughtAmount) - ? -1 - : 1, - ); - instructions.forEach( - ( - { - token, - quote: { - sellAmount: amountWithoutFee, - buyAmount: receivedAmount, - feeAmount: returnedFee, - }, - needsAllowance, - balance: returnedBalance, - }, - sortedIndex, - ) => { - const { balance, fee, boughtAmount, symbol, decimals, index } = - successfulConfigs[sortedIndex]; - expect(token.symbol).to.deep.equal(symbol); - expect(token.decimals).to.deep.equal(decimals); - expect(token.address).to.deep.equal(dumpedTokens[index].address); - expect(needsAllowance).to.deep.equal(isIndexWithoutAllowance(index)); - expect(receivedAmount).to.deep.equal(boughtAmount); - expect(amountWithoutFee).to.deep.equal(balance.sub(fee)); - expect(returnedBalance).to.deep.equal(balance); - expect(returnedFee).to.deep.equal(fee); - }, - ); - }); - - it("adds slippage to quote", async () => { - const to = await mockErc20(deployer); - await to.mock.symbol.returns("TOTOKEN"); - await to.mock.decimals.returns(101); - const dumped = await mockErc20(deployer); - await dumped.mock.symbol.returns("DUMPEDTOKEN"); - await dumped.mock.decimals.returns(0xd); - - const balance = utils.parseEther("42"); - const fee = utils.parseEther("1"); - const allowance = utils.parseEther("31337"); - const boughtAmount = utils.parseEther("100"); - const slippageBps = 100; - const boughtAmountWithSlippage = utils.parseEther("99"); - - await dumped.mock.balanceOf.withArgs(user.address).returns(balance); - await dumped.mock.allowance - .withArgs(user.address, vaultRelayer) - .returns(allowance); - mockApiCalls({ - apiMock, - toToken: to.address, - dumpedToken: dumped.address, - balance, - fee, - boughtAmount, - from: defaultDumpInstructions.user, - }); - - const { instructions } = await getDumpInstructions({ - ...defaultDumpInstructions, - slippageBps, - dumpedTokens: [dumped.address], - toTokenAddress: to.address, - }); - expect(instructions).to.have.length(1); - const { - quote: { buyAmount: receivedAmount }, - } = instructions[0]; - expect(receivedAmount).to.deep.equal(boughtAmountWithSlippage); - }); -}); - -describe("Task: dump", () => { - let deployer: Wallet; - let receiver: Wallet; - let signer: SignerWithAddress; - let apiMock: SinonMock; - let api: Api; - - let settlement: Contract; - let weth: Contract; - let dai: Contract; - - beforeEach(async () => { - const deployment = await deployTestContracts(); - - let signerWallet: Wallet; - ({ - deployer, - settlement, - wallets: [receiver, signerWallet], - } = deployment); - - const foundSigner = (await hre.ethers.getSigners()).find( - (signer) => signer.address == signerWallet.address, - ); - expect(foundSigner).not.to.be.undefined; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - signer = foundSigner!; - - const TestERC20 = await hre.artifacts.readArtifact( - "src/contracts/test/TestERC20.sol:TestERC20", - ); - dai = await waffle.deployContract(deployer, TestERC20, ["DAI", 18]); - weth = await waffle.deployContract(deployer, TestERC20, ["WETH", 18]); - - // environment parameter is unused in mock - const environment = "unset environment" as unknown as Environment; - api = new Api("mock", environment); - apiMock = mock(api); - - // the script checks that the onchain time is not too far from the current - // time - if ( - (await ethers.provider.getBlock("latest")).timestamp < - Math.floor(Date.now() / 1000) - ) { - await synchronizeBlockchainAndCurrentTime(); - } - - useDebugConsole(); - }); - - afterEach(function () { - restoreStandardConsole(); - if (this.currentTest?.isPassed()) { - apiMock.verify(); - } - }); - - it("should dump tokens", async () => { - // Dump dai and weth for weth to a different receiver - const validity = 4242; - - const wethData = { - balance: utils.parseEther("42"), - }; - const daiData = { - balance: utils.parseEther("31337"), - fee: utils.parseEther("1337"), - boughtAmount: utils.parseEther("30"), - }; - - await dai.mint(signer.address, daiData.balance); - await weth.mint(signer.address, wethData.balance); - mockApiCalls({ - ...daiData, - apiMock, - toToken: weth.address, - dumpedToken: dai.address, - from: signer.address, - }); - - api.placeOrder = async function ({ order }: PlaceOrderQuery) { - expect(order.sellToken).to.deep.equal(dai.address); - expect(order.buyToken).to.deep.equal(weth.address); - expect(order.sellAmount).to.deep.equal(daiData.balance.sub(daiData.fee)); - expect(order.buyAmount).to.deep.equal(daiData.boughtAmount); - expect(order.feeAmount).to.deep.equal(daiData.fee); - expect(order.kind).to.deep.equal(OrderKind.SELL); - expect(order.receiver).to.deep.equal(receiver.address); - expect(order.partiallyFillable).to.equal(false); - return "0xorderUid"; - }; - - mockQuerySellingTokenForEth({ - apiMock, - amount: wethData.balance, - token: weth.address, - ethValue: BigNumber.from("1"), // default maxFeePercent is infinity, anything nonzero works - }); - - await dump({ - validity, - maxFeePercent: Infinity, - slippageBps: 0, - dumpedTokens: [weth.address, dai.address], - toToken: weth.address, - settlement, - signer, - receiver: receiver.address, - network, - hre, - api, - gasEstimator: new ProviderGasEstimator(ethers.provider), - dryRun: false, - doNotPrompt: true, - }); - - await expect(weth.balanceOf(receiver.address)).to.eventually.deep.equal( - wethData.balance, - ); - await expect(weth.balanceOf(signer.address)).to.eventually.deep.equal( - constants.AddressZero, - ); - }); - - describe("regressions", () => { - it("should withdraw toToken if it's the only action to perform", async () => { - const balance = utils.parseEther("42"); - await weth.mint(signer.address, balance); - - mockQuerySellingTokenForEth({ - apiMock, - amount: balance, - token: weth.address, - ethValue: BigNumber.from("1"), // default maxFeePercent is infinity, anything nonzero works - }); - - await dump({ - validity: 1337, - maxFeePercent: Infinity, - slippageBps: 0, - dumpedTokens: [weth.address], - toToken: weth.address, - settlement, - signer, - receiver: receiver.address, - network, - hre, - api, - gasEstimator: new ProviderGasEstimator(ethers.provider), - dryRun: false, - doNotPrompt: true, - }); - - await expect(weth.balanceOf(receiver.address)).to.eventually.deep.equal( - balance, - ); - await expect(weth.balanceOf(signer.address)).to.eventually.deep.equal( - constants.AddressZero, - ); - }); - }); -}); diff --git a/test/tasks/logging.ts b/test/tasks/logging.ts deleted file mode 100644 index e7dfaf9a..00000000 --- a/test/tasks/logging.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { assert } from "chai"; -import Debug from "debug"; - -const log = Debug("test:console:log"); -const warn = Debug("test:console:warn"); - -let consoleLog: typeof console.log; -let consoleWarn: typeof console.warn; -let consoleSuppressed = false; - -export const useDebugConsole: () => void = () => { - assert(!consoleSuppressed); - consoleLog = console.log; - console.log = log; - consoleWarn = console.warn; - console.warn = warn; - consoleSuppressed = true; -}; - -export const restoreStandardConsole: () => void = () => { - assert(consoleSuppressed); - console.log = consoleLog; - console.warn = consoleWarn; - consoleSuppressed = false; -}; diff --git a/test/tasks/solvers.test.ts b/test/tasks/solvers.test.ts deleted file mode 100644 index 9bd5aff5..00000000 --- a/test/tasks/solvers.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { expect } from "chai"; -import { Contract, Wallet } from "ethers"; -import { run } from "hardhat"; - -import { deployTestContracts } from "../e2e/fixture"; - -let manager: Wallet; -let solver: Wallet; -let notSolver: Wallet; -let notManager: Wallet; - -let authenticator: Contract; - -describe("Task: solvers", () => { - beforeEach(async () => { - const deployment = await deployTestContracts(); - - ({ - manager, - authenticator, - wallets: [solver, notSolver, notManager], - } = deployment); - - await authenticator.connect(manager).addSolver(solver.address); - }); - - describe("check", () => { - let output: unknown = undefined; - let consoleLog: typeof console.log; - - beforeEach(() => { - consoleLog = console.log; - console.log = (...args: unknown[]) => (output = args[0]); - }); - afterEach(() => { - console.log = consoleLog; - output = undefined; - }); - - it("valid solver", async () => { - await run("solvers", { - subtask: "check", - address: solver.address, - }); - expect(output).to.equal(`${solver.address} is a solver.`); - }); - - it("invalid solver", async () => { - await run("solvers", { - subtask: "check", - address: notSolver.address, - }); - expect(output).to.equal(`${notSolver.address} is NOT a solver.`); - }); - }); - - describe("add", () => { - it("adds a solver", async () => { - expect(await authenticator.isSolver(notSolver.address)).to.be.false; - await run("solvers", { - subtask: "add", - address: notSolver.address, - }); - expect(await authenticator.isSolver(notSolver.address)).to.be.true; - }); - - it("does not add a solver when --print-transaction is specified", async () => { - expect(await authenticator.isSolver(notSolver.address)).to.be.false; - await run("solvers", { - subtask: "add", - address: notSolver.address, - printTransaction: true, - }); - expect(await authenticator.isSolver(notSolver.address)).to.be.false; - }); - }); - - describe("remove", () => { - it("removes a solver", async () => { - expect(await authenticator.isSolver(solver.address)).to.be.true; - await run("solvers", { - subtask: "remove", - address: solver.address, - }); - expect(await authenticator.isSolver(solver.address)).to.be.false; - }); - - it("does not remove a solver when --print-transaction is specified", async () => { - expect(await authenticator.isSolver(solver.address)).to.be.true; - await run("solvers", { - subtask: "remove", - address: solver.address, - printTransaction: true, - }); - expect(await authenticator.isSolver(solver.address)).to.be.true; - }); - }); - - describe("setManager", () => { - it("sets the manager", async () => { - expect(await authenticator.manager()).not.to.equal(notManager.address); - await run("solvers", { - subtask: "setManager", - address: notManager.address, - }); - expect(await authenticator.manager()).to.equal(notManager.address); - }); - - it("does not set the manager when --print-transaction is specified", async () => { - expect(await authenticator.manager()).not.to.equal(notManager.address); - await run("solvers", { - subtask: "setManager", - address: notManager.address, - printTransaction: true, - }); - expect(await authenticator.manager()).not.to.equal(notManager.address); - }); - }); -}); diff --git a/test/tasks/ts/gas.test.ts b/test/tasks/ts/gas.test.ts deleted file mode 100644 index 66ca894d..00000000 --- a/test/tasks/ts/gas.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { expect } from "chai"; - -import { BlockNativeGasEstimator } from "../../../src/tasks/ts/gas"; - -// We allow failure for BlockNative tests because their service may be down, and -// we don't want to block CI because of it. -// -// The tests in this file still provide an indication that things are working -// and should pass most of the time. -function itAllowFail(title: string, callback: () => Promise) { - it(title, async function () { - try { - await callback(); - } catch (err) { - console.warn(`allowed failure: ${err}`); - this.skip(); - } - }); -} - -describe("Task helper: BlockNative gas estimator", () => { - const blockNative = new BlockNativeGasEstimator(); - - itAllowFail("estimates gas", async () => { - const gasPrice = await blockNative.gasPriceEstimate(); - - expect(gasPrice.gt(0)).to.be.true; - }); - - itAllowFail("computes transaction gas prices", async () => { - const { gasPrice, maxFeePerGas, maxPriorityFeePerGas } = - await blockNative.txGasPrice(); - - expect(gasPrice).to.be.undefined; - - expect(maxFeePerGas).to.not.be.undefined; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(maxFeePerGas!.gt(0)).to.be.true; - - expect(maxPriorityFeePerGas).to.not.be.undefined; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(maxPriorityFeePerGas!.gt(0)).to.be.true; - }); -}); diff --git a/test/tasks/ts/solver.test.ts b/test/tasks/ts/solver.test.ts deleted file mode 100644 index d8579060..00000000 --- a/test/tasks/ts/solver.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { expect } from "chai"; -import { Contract, Wallet } from "ethers"; - -import { getSolvers } from "../../../src/tasks/ts/solver"; -import { deployTestContracts } from "../../e2e/fixture"; - -let manager: Wallet; -let wallets: Wallet[]; - -let authenticator: Contract; - -describe("Task helper: getSolvers", () => { - beforeEach(async () => { - const deployment = await deployTestContracts(); - - ({ manager, authenticator, wallets } = deployment); - }); - - it("lists solvers", async () => { - await authenticator.connect(manager).addSolver(wallets[0].address); - await authenticator.connect(manager).addSolver(wallets[1].address); - const list = await getSolvers(authenticator); - expect(list).to.have.length(2); - expect(list).to.include(wallets[0].address); - expect(list).to.include(wallets[1].address); - }); - - it("does not show removed solvers", async () => { - await authenticator.connect(manager).addSolver(wallets[0].address); - await authenticator.connect(manager).removeSolver(wallets[0].address); - const list = await getSolvers(authenticator); - expect(list).to.have.length(0); - }); -}); diff --git a/test/tasks/ts/tokens.test.ts b/test/tasks/ts/tokens.test.ts deleted file mode 100644 index 50e80ab0..00000000 --- a/test/tasks/ts/tokens.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { expect } from "chai"; -import { constants, Contract, utils } from "ethers"; -import hre, { ethers, waffle, artifacts } from "hardhat"; - -import { - transfer, - balanceOf, - nativeToken, - erc20Token, - isNativeToken, - Erc20Token, - NativeToken, -} from "../../../src/tasks/ts/tokens"; - -describe("token tools", () => { - const [deployer, user] = waffle.provider.getWallets(); - const receiver = "0x" + "42".repeat(20); - - let token: Contract; - let erc20: Erc20Token; - let native: NativeToken; - - beforeEach(async () => { - const IERC20 = await artifacts.readArtifact( - "src/contracts/interfaces/IERC20.sol:IERC20", - ); - - token = await waffle.deployMockContract(deployer, IERC20.abi); - - const recoveredErc20 = await erc20Token(token.address, hre); - if (recoveredErc20 === null) { - throw new Error("Erc20 token not decoded"); - } - erc20 = recoveredErc20; - native = await nativeToken(hre); - }); - - describe("erc20Token", () => { - it("recovers token info", async () => { - await token.mock.symbol.withArgs().returns("SYM"); - await token.mock.decimals.withArgs().returns(18); - - const recoveredErc20 = await erc20Token(token.address, hre); - expect(recoveredErc20).not.to.be.null; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - erc20 = recoveredErc20!; - - expect(erc20.address).to.equal(token.address); - expect(erc20.symbol).to.equal("SYM"); - expect(erc20.decimals).to.equal(18); - }); - - it("returns null for addresses with no code", async () => { - expect(await erc20Token(constants.AddressZero, hre)).to.be.null; - }); - }); - - it("isNativeToken", async () => { - expect(await isNativeToken(erc20)).to.be.false; - expect(await isNativeToken(native)).to.be.true; - }); - - describe("balance", () => { - it("erc20", async () => { - const balance = utils.parseEther("13.37"); - await token.mock.balanceOf.withArgs(user.address).returns(balance); - - expect(await balanceOf(erc20, user.address)).to.deep.equal(balance); - }); - - it("native token", async () => { - const value = utils.parseEther("13.37"); - await user.sendTransaction({ to: constants.AddressZero, value }); - - expect(await balanceOf(native, constants.AddressZero)).to.deep.equal( - value, - ); - }); - }); - - describe("transfer", () => { - it("erc20", async () => { - const amount = utils.parseEther("13.37"); - await token.mock.transfer.withArgs(receiver, amount).returns(true); - - await transfer(erc20, user, receiver, amount); - expect(await ethers.provider.getBalance(receiver)).to.deep.equal( - constants.Zero, - ); - }); - - it("native token", async () => { - const amount = utils.parseEther("13.37"); - - await transfer(native, user, receiver, amount); - expect(await ethers.provider.getBalance(receiver)).to.deep.equal(amount); - }); - }); -}); diff --git a/test/tasks/withdraw.test.ts b/test/tasks/withdraw.test.ts deleted file mode 100644 index bac17f58..00000000 --- a/test/tasks/withdraw.test.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; -import { expect } from "chai"; -import { BigNumber, constants, Contract, utils, Wallet } from "ethers"; -import hre, { ethers, waffle } from "hardhat"; -import { mock, SinonMock } from "sinon"; - -import { SupportedNetwork } from "../../src/tasks/ts/deployment"; -import { ProviderGasEstimator } from "../../src/tasks/ts/gas"; -import { ReferenceToken } from "../../src/tasks/ts/value"; -import { withdraw } from "../../src/tasks/withdraw"; -import { - OrderKind, - SettlementEncoder, - SigningScheme, - TypedDataDomain, - domain, -} from "../../src/ts"; -import { Api, Environment } from "../../src/ts/api"; -import { deployTestContracts } from "../e2e/fixture"; - -import { restoreStandardConsole, useDebugConsole } from "./logging"; - -interface MockQuerySellingEthForUsdInput { - apiMock: SinonMock; - amount: BigNumber; - usdReference: ReferenceToken; - usdValue: BigNumber; -} -export function mockQuerySellingEthForUsd({ - apiMock, - amount, - usdReference, - usdValue, -}: MockQuerySellingEthForUsdInput): void { - apiMock - .expects("estimateTradeAmount") - .withArgs({ - sellToken: undefined, // note: weth is undefined for the hardhat network - buyToken: usdReference.address, - kind: OrderKind.SELL, - amount, - }) - .once() - .returns(Promise.resolve(usdValue)); -} - -// Executes trades between the input tokens in order to emit trade events. -export async function tradeTokensForNoFees( - tokens: Contract[], - trader: Wallet, - domainSeparator: TypedDataDomain, - settlement: Contract, - vaultRelayer: Contract, - solver: SignerWithAddress, -): Promise { - const encoder = new SettlementEncoder(domainSeparator); - - const consecutiveTokenPairs = tokens.map((token, index) => [ - token, - tokens[(index + 1) % tokens.length], - ]); - for (const [sell, buy] of consecutiveTokenPairs) { - await sell.mint(trader.address, 1); - await sell.connect(trader).approve(vaultRelayer.address, 1); - await encoder.signEncodeTrade( - { - kind: OrderKind.SELL, - partiallyFillable: false, - sellToken: sell.address, - buyToken: buy.address, - sellAmount: 1, - buyAmount: 1, - feeAmount: 0, - validTo: 0xffffffff, - appData: 0, - }, - trader, - SigningScheme.EIP712, - ); - } - const prices: Record = {}; - tokens.forEach((token) => { - prices[token.address] = 1; - }); - await settlement.connect(solver).settle(...encoder.encodedSettlement(prices)); -} - -describe("Task: withdraw", () => { - let deployer: Wallet; - let solver: SignerWithAddress; - let trader: Wallet; - let receiver: Wallet; - - let settlement: Contract; - let authenticator: Contract; - - let weth: Contract; - let usdc: Contract; - let dai: Contract; - - let apiMock: SinonMock; - let api: Api; - - const usdReference: ReferenceToken = { - address: "0x" + "42".repeat(20), - symbol: "USD", - decimals: 42, - }; - - beforeEach(async () => { - const deployment = await deployTestContracts(); - - let solverWallet: Wallet; - ({ - deployer, - settlement, - authenticator, - wallets: [solverWallet, receiver, trader], - } = deployment); - const foundSolver = (await ethers.getSigners()).find( - (signer) => signer.address == solverWallet.address, - ); - expect(foundSolver).not.to.be.undefined; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - solver = foundSolver!; - - const TestERC20 = await hre.artifacts.readArtifact( - "src/contracts/test/TestERC20.sol:TestERC20", - ); - dai = await waffle.deployContract(deployer, TestERC20, ["DAI", 18]); - usdc = await waffle.deployContract(deployer, TestERC20, ["USDC", 6]); - weth = await waffle.deployContract(deployer, TestERC20, ["WETH", 18]); - - // environment parameter is unused in mock - const environment = "unset environment" as unknown as Environment; - api = new Api("mock", environment); - apiMock = mock(api); - - const { manager, vaultRelayer } = deployment; - await authenticator.connect(manager).addSolver(solver.address); - - const { chainId } = await ethers.provider.getNetwork(); - const domainSeparator = domain(chainId, settlement.address); - // Trade in order to test automatic retrieval of traded addresses. - await tradeTokensForNoFees( - [usdc, weth, dai], - trader, - domainSeparator, - settlement, - vaultRelayer, - solver, - ); - - useDebugConsole(); - }); - - afterEach(function () { - restoreStandardConsole(); - if (this.currentTest?.isPassed()) { - apiMock.verify(); - } - }); - - it("should withdraw tokens from the settlement contract", async () => { - const minValue = "8.0"; - const leftover = "9.0"; - const ethUsdValue = 1000; - // dai is the only token that should not be withdrawn as it's below the - // parameter threshold of 8+9=17 - const daiBalance = utils.parseUnits("10.0", 18); - await dai.mint(settlement.address, daiBalance); - const usdcBalance = utils.parseUnits("20.0", 6); - await usdc.mint(settlement.address, usdcBalance); - const wethBalance = utils.parseUnits("0.02", 18); - await weth.mint(settlement.address, wethBalance); - - expect(await dai.balanceOf(receiver.address)).to.deep.equal(constants.Zero); - expect(await usdc.balanceOf(receiver.address)).to.deep.equal( - constants.Zero, - ); - expect(await weth.balanceOf(receiver.address)).to.deep.equal( - constants.Zero, - ); - - // query to get eth price - mockQuerySellingEthForUsd({ - apiMock, - amount: utils.parseEther("1"), - usdReference, - usdValue: utils.parseUnits(ethUsdValue.toString(), usdReference.decimals), - }); - - apiMock - .expects("estimateTradeAmount") - .withArgs({ - sellToken: usdc.address, - buyToken: usdReference.address, - kind: OrderKind.SELL, - amount: usdcBalance, - }) - .once() - .returns( - Promise.resolve( - usdcBalance.mul(BigNumber.from(10).pow(usdReference.decimals - 6)), - ), - ); - apiMock - .expects("estimateTradeAmount") - .withArgs({ - sellToken: dai.address, - buyToken: usdReference.address, - kind: OrderKind.SELL, - amount: daiBalance, - }) - .once() - .returns( - Promise.resolve( - daiBalance.mul(BigNumber.from(10).pow(usdReference.decimals - 18)), - ), - ); - apiMock - .expects("estimateTradeAmount") - .withArgs({ - sellToken: weth.address, - buyToken: usdReference.address, - kind: OrderKind.SELL, - amount: wethBalance, - }) - .once() - .returns( - Promise.resolve( - Promise.resolve( - wethBalance - .mul(ethUsdValue) - .mul(BigNumber.from(10).pow(usdReference.decimals - 18)), - ), - ), - ); - - const withdrawnTokens = await withdraw({ - solver, - receiver: receiver.address, - authenticator, - settlement, - settlementDeploymentBlock: 0, - minValue, - leftover, - maxFeePercent: Infinity, - tokens: undefined, - usdReference, - // ignored network value - network: undefined as unknown as SupportedNetwork, - hre, - api, - gasEstimator: new ProviderGasEstimator(ethers.provider), - dryRun: false, - doNotPrompt: true, - }); - - expect(withdrawnTokens).to.have.length(2); - expect(withdrawnTokens).to.include(usdc.address); - expect(withdrawnTokens).to.include(weth.address); - expect(await dai.balanceOf(receiver.address)).to.deep.equal(constants.Zero); - expect(await usdc.balanceOf(receiver.address)).to.deep.equal( - usdcBalance.sub(utils.parseUnits(leftover, 6)), - ); - expect(await weth.balanceOf(receiver.address)).to.deep.equal( - wethBalance.sub(utils.parseUnits(leftover, 18).div(ethUsdValue)), - ); - }); - - it("should withdraw only tokens for which the gas fee is not too high", async () => { - const minValue = "0"; - const leftover = "0"; - // This value was chosen so that the dai withdraw would not succeed because - // the gas fee is too expensive, but the weth one would. - // If this test fails, it could be because the Ethereum implementation of - // the test node changed the gas cost of some opcodes. You can update this - // value by running the test with this value set to zero and printing the - // output of the test with the DEBUG flag. In the output, you can find - // something similar to: - // ``` - // test:console:log Ignored 10.0 units of DAI (0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6) with value 10.00 USD, the gas cost of including this transaction is too high (0.42% of the withdrawn amount) +2s - // test:console:log Ignored 0.02 units of WETH (0x610178dA211FEF7D417bC0e6FeD39F05609AD788) with value 20.00 USD, the gas cost of including this transaction is too high (0.21% of the withdrawn amount) +0ms - // ``` - // Then, change the max fee percentage to a number between the two percent - // values from the logs. - const maxFeePercent = 0.3; - const ethUsdValue = 1000; - - const daiBalance = utils.parseUnits("10.0", 18); - await dai.mint(settlement.address, daiBalance); - // The weth balance has double the value of the dai balance. The two values - // can't be too close, as otherwise the test would be too sensitive to - // small changes in gas when running the test, but also not too far so that - // errors in the math would cause an error in the test. - const wethBalance = utils.parseUnits("0.02", 18); - await weth.mint(settlement.address, wethBalance); - - expect(await dai.balanceOf(receiver.address)).to.deep.equal(constants.Zero); - expect(await weth.balanceOf(receiver.address)).to.deep.equal( - constants.Zero, - ); - - // query to get eth price - mockQuerySellingEthForUsd({ - apiMock, - amount: utils.parseEther("1"), - usdReference, - usdValue: utils.parseUnits(ethUsdValue.toString(), usdReference.decimals), - }); - - apiMock - .expects("estimateTradeAmount") - .withArgs({ - sellToken: dai.address, - buyToken: usdReference.address, - kind: OrderKind.SELL, - amount: daiBalance, - }) - .once() - .returns( - Promise.resolve( - daiBalance.mul(BigNumber.from(10).pow(usdReference.decimals - 18)), - ), - ); - apiMock - .expects("estimateTradeAmount") - .withArgs({ - sellToken: weth.address, - buyToken: usdReference.address, - kind: OrderKind.SELL, - amount: wethBalance, - }) - .once() - .returns( - Promise.resolve( - Promise.resolve( - wethBalance - .mul(ethUsdValue) - .mul(BigNumber.from(10).pow(usdReference.decimals - 18)), - ), - ), - ); - - const withdrawnTokens = await withdraw({ - solver, - receiver: receiver.address, - authenticator, - settlement, - settlementDeploymentBlock: 0, - minValue, - leftover, - maxFeePercent, - tokens: undefined, - usdReference, - // ignored network value - network: undefined as unknown as SupportedNetwork, - hre, - api, - gasEstimator: new ProviderGasEstimator(ethers.provider), - dryRun: false, - doNotPrompt: true, - }); - - expect(withdrawnTokens).to.have.length(1); - expect(withdrawnTokens).to.include(weth.address); - expect(await dai.balanceOf(receiver.address)).to.deep.equal(constants.Zero); - expect(await weth.balanceOf(receiver.address)).to.deep.equal(wethBalance); - }); -}); diff --git a/test/tasks/withdrawService.test.ts b/test/tasks/withdrawService.test.ts deleted file mode 100644 index 8dc6311c..00000000 --- a/test/tasks/withdrawService.test.ts +++ /dev/null @@ -1,654 +0,0 @@ -import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; -import { expect, use } from "chai"; -import chaiAsPromised from "chai-as-promised"; -import { BigNumber, constants, Contract, utils, Wallet } from "ethers"; -import hre, { ethers, waffle } from "hardhat"; -import { mock, SinonMock, match } from "sinon"; - -import { APP_DATA } from "../../src/tasks/dump"; -import { SupportedNetwork } from "../../src/tasks/ts/deployment"; -import { ProviderGasEstimator } from "../../src/tasks/ts/gas"; -import { ReferenceToken } from "../../src/tasks/ts/value"; -import * as withdrawService from "../../src/tasks/withdrawService"; -import { WithdrawAndDumpInput } from "../../src/tasks/withdrawService"; -import { OrderKind, domain, Order } from "../../src/ts"; -import { Api, Environment, PlaceOrderQuery } from "../../src/ts/api"; -import { deployTestContracts } from "../e2e/fixture"; -import { synchronizeBlockchainAndCurrentTime } from "../hardhatNetwork"; - -import { restoreStandardConsole, useDebugConsole } from "./logging"; -import { - mockQuerySellingEthForUsd, - tradeTokensForNoFees, -} from "./withdraw.test"; - -use(chaiAsPromised); - -describe("Task: withdrawService", () => { - let deployer: Wallet; - let solver: SignerWithAddress; - let trader: Wallet; - let receiver: Wallet; - - let settlement: Contract; - let authenticator: Contract; - let vaultRelayer: Contract; - - let weth: Contract; - let usdc: Contract; - let dai: Contract; - let toToken: Contract; - - let apiMock: SinonMock; - let api: Api; - - let withdrawAndDumpDefaultParams: () => Promise< - Omit - >; - - const usdReference: ReferenceToken = { - address: "0x" + "42".repeat(20), - symbol: "USD", - decimals: 42, - }; - - beforeEach(async () => { - const deployment = await deployTestContracts(); - - let solverWallet: Wallet; - ({ - deployer, - settlement, - authenticator, - vaultRelayer, - wallets: [solverWallet, receiver, trader], - } = deployment); - const foundSolver = (await ethers.getSigners()).find( - (signer) => signer.address == solverWallet.address, - ); - expect(foundSolver).not.to.be.undefined; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - solver = foundSolver!; - - const TestERC20 = await hre.artifacts.readArtifact( - "src/contracts/test/TestERC20.sol:TestERC20", - ); - dai = await waffle.deployContract(deployer, TestERC20, ["DAI", 18]); - usdc = await waffle.deployContract(deployer, TestERC20, ["USDC", 6]); - weth = await waffle.deployContract(deployer, TestERC20, ["WETH", 18]); - toToken = await waffle.deployContract(deployer, TestERC20, ["toToken", 2]); - - // environment parameter is unused in mock - const environment = "unset environment" as unknown as Environment; - api = new Api("mock", environment); - apiMock = mock(api); - - const { manager } = deployment; - await authenticator.connect(manager).addSolver(solver.address); - - const { chainId } = await ethers.provider.getNetwork(); - const domainSeparator = domain(chainId, settlement.address); - // Trade in order to test automatic retrieval of traded addresses. - await tradeTokensForNoFees( - [usdc, weth, dai], - trader, - domainSeparator, - settlement, - vaultRelayer, - solver, - ); - - withdrawAndDumpDefaultParams = async () => ({ - solver, - receiver: receiver.address, - authenticator, - settlement, - settlementDeploymentBlock: 0, - minValue: "0", - leftover: "0", - validity: 3600, - maxFeePercent: 100, - slippageBps: 0, - toToken: toToken.address, - // ignore network value - network: undefined as unknown as SupportedNetwork, - usdReference, - hre, - api, - gasEstimator: new ProviderGasEstimator(ethers.provider), - dryRun: false, - }); - - // the script checks that the onchain time is not too far from the current - // time - if ( - (await ethers.provider.getBlock("latest")).timestamp < - Math.floor(Date.now() / 1000) - ) { - await synchronizeBlockchainAndCurrentTime(); - } - - useDebugConsole(); - }); - - afterEach(function () { - restoreStandardConsole(); - if (this.currentTest?.isPassed()) { - apiMock.verify(); - } - }); - - it("should withdraw and dump", async () => { - const initalState: withdrawService.State = { - lastUpdateBlock: 0, - tradedTokens: [], - nextTokenToTrade: 0, - pendingTokens: [ - // There are pending tokens that simulate a previous run of the script - // that tried to withdraw these tokens a number of times. - { address: dai.address, retries: 3 }, - { address: usdc.address, retries: 4 }, - ], - }; - const defaultParams = await withdrawAndDumpDefaultParams(); - const solverDaiBalance = BigNumber.from(utils.parseUnits("100.0", 18)); - // some dai are left over in the solver address from a previous run - await dai.mint(solver.address, solverDaiBalance); - // no usdc balance is there, which means that the usdc entry should not - // affect the final result (this would occur in practice if for example they - // were withdrawn in the previous run of the script) - - const minValue = "5.0"; - const leftover = "10.0"; - const ethUsdValue = 1000; - const daiBalance = utils.parseUnits("20.0", 18); - await dai.mint(settlement.address, daiBalance); - const usdcBalance = utils.parseUnits("30.0", 6); - await usdc.mint(settlement.address, usdcBalance); - const wethBalance = utils.parseUnits("0.04", 18); - await weth.mint(settlement.address, wethBalance); - const daiBalanceMinusLeftover = utils.parseUnits("10.0", 18); - const usdcBalanceMinusLeftover = utils.parseUnits("20.0", 6); - const wethBalanceMinusLeftover = utils.parseUnits("0.03", 18); - - expect(await dai.balanceOf(receiver.address)).to.deep.equal(constants.Zero); - expect(await usdc.balanceOf(receiver.address)).to.deep.equal( - constants.Zero, - ); - expect(await weth.balanceOf(receiver.address)).to.deep.equal( - constants.Zero, - ); - - // query to get eth price for the withdraw script - mockQuerySellingEthForUsd({ - apiMock, - amount: utils.parseEther("1"), - usdReference, - usdValue: utils.parseUnits(ethUsdValue.toString(), usdReference.decimals), - }); - - apiMock - .expects("estimateTradeAmount") - .withArgs({ - sellToken: usdc.address, - buyToken: usdReference.address, - kind: OrderKind.SELL, - amount: usdcBalance, - }) - .once() - .returns( - Promise.resolve( - usdcBalance.mul(BigNumber.from(10).pow(usdReference.decimals - 6)), - ), - ); - apiMock - .expects("estimateTradeAmount") - .withArgs({ - sellToken: dai.address, - buyToken: usdReference.address, - kind: OrderKind.SELL, - amount: daiBalance, - }) - .once() - .returns( - Promise.resolve( - daiBalance.mul(BigNumber.from(10).pow(usdReference.decimals - 18)), - ), - ); - apiMock - .expects("estimateTradeAmount") - .withArgs({ - sellToken: weth.address, - buyToken: usdReference.address, - kind: OrderKind.SELL, - amount: wethBalance, - }) - .once() - .returns( - Promise.resolve( - wethBalance - .mul(ethUsdValue) - .mul(BigNumber.from(10).pow(usdReference.decimals - 18)), - ), - ); - - // fee is low except for dai, where it's larger than the maximum allowed - const maxFeePercent = 25; - const usdcFee = usdcBalance.div(42); - const usdcFeeAndQuote = { - quote: { - feeAmount: usdcFee, - buyAmount: BigNumber.from(42), - sellAmount: BigNumber.from(usdcBalanceMinusLeftover).sub(usdcFee), - }, - }; - const validity = 3600; - - apiMock - .expects("getQuote") - .withArgs({ - sellToken: usdc.address, - buyToken: toToken.address, - validTo: match.any, - appData: APP_DATA, - partiallyFillable: false, - from: defaultParams.solver.address, - kind: OrderKind.SELL, - sellAmountBeforeFee: usdcBalanceMinusLeftover, - }) - .twice() - .returns(Promise.resolve(usdcFeeAndQuote)); - // the solver was storing dai balance from the previous run, which - // should be included - const daiBalanceIncludingSolver = - daiBalanceMinusLeftover.add(solverDaiBalance); - const daiFee = daiBalanceIncludingSolver.div(2); - const daiFeeAndQuote = { - quote: { - feeAmount: BigNumber.from(daiFee), - buyAmount: BigNumber.from(1337), - sellAmount: BigNumber.from(daiBalanceIncludingSolver).sub(daiFee), - }, - }; - apiMock - .expects("getQuote") - .withArgs({ - sellToken: dai.address, - buyToken: toToken.address, - validTo: match.any, - appData: APP_DATA, - partiallyFillable: false, - from: defaultParams.solver.address, - kind: OrderKind.SELL, - sellAmountBeforeFee: daiBalanceIncludingSolver, - }) - .once() - .returns(Promise.resolve(daiFeeAndQuote)); - const wethFee = wethBalance.div(1337); - const wethFeeAndQuote = { - quote: { - feeAmount: wethFee, - buyAmount: BigNumber.from(1337), - sellAmount: BigNumber.from(wethBalanceMinusLeftover).sub(wethFee), - }, - }; - - apiMock - .expects("getQuote") - .withArgs({ - sellToken: weth.address, - buyToken: toToken.address, - validTo: match.any, - appData: APP_DATA, - partiallyFillable: false, - from: defaultParams.solver.address, - kind: OrderKind.SELL, - sellAmountBeforeFee: wethBalanceMinusLeftover, - }) - .twice() - .returns(Promise.resolve(wethFeeAndQuote)); - - function assertGoodOrder( - order: Order, - sellToken: string, - sellAmount: BigNumber, - buyAmount: BigNumber, - feeAmount: BigNumber, - ) { - expect(order.sellToken).to.deep.equal(sellToken); - expect(order.buyToken).to.deep.equal(toToken.address); - expect(order.sellAmount).to.deep.equal(sellAmount); - expect(order.buyAmount).to.deep.equal(buyAmount); - expect(order.feeAmount).to.deep.equal(feeAmount); - expect(order.kind).to.deep.equal(OrderKind.SELL); - expect(order.receiver).to.deep.equal(receiver.address); - expect(order.partiallyFillable).to.equal(false); - } - api.placeOrder = async function ({ order }: PlaceOrderQuery) { - switch (order.sellToken) { - case usdc.address: { - assertGoodOrder( - order, - usdc.address, - usdcBalanceMinusLeftover.sub(usdcFeeAndQuote.quote.feeAmount), - usdcFeeAndQuote.quote.buyAmount, - usdcFeeAndQuote.quote.feeAmount, - ); - return "0xusdcOrderUid"; - } - case weth.address: { - assertGoodOrder( - order, - weth.address, - wethBalanceMinusLeftover.sub(wethFeeAndQuote.quote.feeAmount), - wethFeeAndQuote.quote.buyAmount, - wethFeeAndQuote.quote.feeAmount, - ); - return "0xwethOrderUid"; - } - default: - throw new Error( - `Invalid sell token ${order.sellToken} in mock order`, - ); - } - }; - - const updatedState = await withdrawService.withdrawAndDump({ - ...defaultParams, - state: initalState, - minValue, - leftover, - validity, - maxFeePercent, - }); - - expect(await usdc.allowance(solver.address, vaultRelayer.address)).to.equal( - constants.MaxUint256, - ); - expect(await weth.allowance(solver.address, vaultRelayer.address)).to.equal( - constants.MaxUint256, - ); - // note: dai is not traded as fees are too high - expect(await dai.allowance(solver.address, vaultRelayer.address)).to.equal( - constants.Zero, - ); - - expect(updatedState.lastUpdateBlock).not.to.equal( - initalState.lastUpdateBlock, - ); - // there are only three tokens, so the next token to trade is again the one - // we started with - expect(updatedState.nextTokenToTrade).to.equal( - initalState.nextTokenToTrade, - ); - expect(updatedState.tradedTokens).to.have.length(3); - expect( - [usdc, dai, weth].filter( - (t) => !updatedState.tradedTokens.includes(t.address), - ), - ).to.be.empty; - expect(updatedState.pendingTokens).to.have.length(3); - // this is the fourth retry for dai, the number of retries should be updated - expect(updatedState.pendingTokens).to.deep.include({ - address: dai.address, - retries: 4, - }); - // the other two start their counter from one, including usdc which was - // present in the initial state but was already withdrawn - expect(updatedState.pendingTokens).to.deep.include({ - address: usdc.address, - retries: 1, - }); - expect(updatedState.pendingTokens).to.deep.include({ - address: weth.address, - retries: 1, - }); - }); - - it("should respect pagination", async () => { - const initalState: withdrawService.State = { - lastUpdateBlock: 0, - // note: token order is set in the initial state - tradedTokens: [dai.address, usdc.address, weth.address], - nextTokenToTrade: 0, - pendingTokens: [], - }; - const defaultParams = await withdrawAndDumpDefaultParams(); - - const daiBalance = utils.parseUnits("21.0", 18); - await dai.mint(settlement.address, daiBalance); - const usdcBalance = utils.parseUnits("42.0", 6); - await usdc.mint(settlement.address, usdcBalance); - const wethBalance = utils.parseUnits("63.0", 18); - await weth.mint(settlement.address, wethBalance); - - async function setupExpectations( - token: Contract, - soldAmount: BigNumber, - ): Promise { - // value - apiMock - .expects("estimateTradeAmount") - .withArgs({ - sellToken: token.address, - buyToken: usdReference.address, - kind: OrderKind.SELL, - amount: soldAmount, - }) - .once() - .returns( - soldAmount.mul( - BigNumber.from(10).pow( - usdReference.decimals - (await token.decimals()), - ), - ), - ); // stablecoin, so amount in is usd value - // fee and received amount - const feeAndQuote = { - quote: { - feeAmount: constants.Zero, - buyAmount: BigNumber.from(1337), - sellAmount: soldAmount, - }, - }; - apiMock - .expects("getQuote") - .withArgs({ - sellToken: token.address, - buyToken: toToken.address, - validTo: match.any, - appData: APP_DATA, - partiallyFillable: false, - from: defaultParams.solver.address, - kind: OrderKind.SELL, - sellAmountBeforeFee: soldAmount, - }) - .twice() - .returns(Promise.resolve(feeAndQuote)); - } - - // query to get eth price for the withdraw script - mockQuerySellingEthForUsd({ - apiMock, - amount: utils.parseEther("1"), - usdReference, - usdValue: constants.Zero, - }); - - await setupExpectations(dai, daiBalance); - await setupExpectations(usdc, usdcBalance); - - let hasSoldUsdc = false; - let hasSoldDai = false; - let hasSoldWeth = false; - api.placeOrder = async function ({ order }: PlaceOrderQuery) { - switch (order.sellToken) { - case dai.address: { - hasSoldDai = true; - return "0xdaiOrderUid"; - } - case usdc.address: { - hasSoldUsdc = true; - return "0xusdcOrderUid"; - } - case weth.address: { - hasSoldWeth = true; - return "0xwethOrderUid"; - } - default: - throw new Error( - `Invalid sell token ${order.sellToken} in mock order`, - ); - } - }; - - const pagination = 2; - const intermediateState = await withdrawService.withdrawAndDump({ - ...defaultParams, - state: initalState, - pagination, - }); - - expect(intermediateState.tradedTokens).to.deep.equal( - initalState.tradedTokens, - ); - expect(intermediateState.nextTokenToTrade).to.deep.equal(2); - expect(intermediateState.lastUpdateBlock).not.to.deep.equal(constants.Zero); - expect(hasSoldDai).to.be.true; - expect(hasSoldUsdc).to.be.true; - expect(hasSoldWeth).to.be.false; - expect(intermediateState.pendingTokens).to.have.length(2); - expect(intermediateState.pendingTokens).to.deep.include({ - address: dai.address, - retries: 1, - }); - expect(intermediateState.pendingTokens).to.deep.include({ - address: usdc.address, - retries: 1, - }); - - expect(await dai.balanceOf(settlement.address)).to.deep.equal( - constants.Zero, - ); - expect(await usdc.balanceOf(settlement.address)).to.deep.equal( - constants.Zero, - ); - expect(await weth.balanceOf(settlement.address)).to.deep.equal(wethBalance); - - // simulate order execution (without sending anything to the receiver) - await dai.connect(solver).burn(daiBalance); - await usdc.connect(solver).burn(usdcBalance); - - // dai was withdrawn previously, so it needs new balances to be traded again - await dai.mint(settlement.address, daiBalance.sub(42)); - - hasSoldUsdc = false; - hasSoldDai = false; - hasSoldWeth = false; - - // query to get eth price for the withdraw script - mockQuerySellingEthForUsd({ - apiMock, - amount: utils.parseEther("1"), - usdReference, - usdValue: constants.Zero, - }); - - await setupExpectations(weth, wethBalance); - await setupExpectations(dai, daiBalance.sub(42)); - - const finalState = await withdrawService.withdrawAndDump({ - ...(await withdrawAndDumpDefaultParams()), - state: intermediateState, - pagination, - }); - - expect(finalState.tradedTokens).to.deep.equal(initalState.tradedTokens); - expect(finalState.nextTokenToTrade).to.deep.equal(1); - expect(finalState.lastUpdateBlock).not.to.deep.equal(constants.Zero); - expect(hasSoldDai).to.be.true; - expect(hasSoldUsdc).to.be.false; - expect(hasSoldWeth).to.be.true; - expect(finalState.pendingTokens).to.have.length(2); - expect(finalState.pendingTokens).to.deep.include({ - address: weth.address, - retries: 1, - }); - expect(finalState.pendingTokens).to.deep.include({ - address: dai.address, - retries: 1, - }); - - expect(await dai.balanceOf(settlement.address)).to.deep.equal( - constants.Zero, - ); - expect(await usdc.balanceOf(settlement.address)).to.deep.equal( - constants.Zero, - ); - expect(await weth.balanceOf(settlement.address)).to.deep.equal( - constants.Zero, - ); - }); - - describe("validates chain id", function () { - let chainId: number; - - beforeEach(async function () { - ({ chainId } = await ethers.provider.getNetwork()); - }); - - it("throws if the state chain id is incorect", async () => { - const badChainId = 42; - expect(chainId).not.to.equal(badChainId); - const initalState: withdrawService.State = { - lastUpdateBlock: 0, - tradedTokens: [], - nextTokenToTrade: 0, - pendingTokens: [], - chainId: badChainId, - }; - - await expect( - withdrawService.withdrawAndDump({ - ...(await withdrawAndDumpDefaultParams()), - state: initalState, - }), - ).to.eventually.be.rejectedWith( - `Current state file was created on chain id ${badChainId}, current chain id is ${chainId}.`, - ); - }); - - it("fills in chain id if state has no chain id", async () => { - const initalState: withdrawService.State = { - lastUpdateBlock: 0, - tradedTokens: [], - nextTokenToTrade: 0, - pendingTokens: [], - }; - - const finalState = await withdrawService.withdrawAndDump({ - ...(await withdrawAndDumpDefaultParams()), - state: initalState, - }); - - expect(finalState.chainId).to.equal(chainId); - }); - - it("succeeds with same chain id", async () => { - const initalState: withdrawService.State = { - lastUpdateBlock: 0, - tradedTokens: [], - nextTokenToTrade: 0, - pendingTokens: [], - chainId, - }; - - const finalState = await withdrawService.withdrawAndDump({ - ...(await withdrawAndDumpDefaultParams()), - state: initalState, - }); - - expect(finalState.chainId).to.equal(chainId); - }); - }); -});