From 3061e41a202a66780824a7f96e1beca7af0d235e Mon Sep 17 00:00:00 2001 From: Paul <108695806+pxrl@users.noreply.github.com> Date: Fri, 8 Sep 2023 14:06:55 +0200 Subject: [PATCH] feat: HubPool helper script (#922) This script can be used to: - Search (scrape) arbitrary HubPool events (ProposeRootBundle, DisputeRootBundle, ...) - Initiate a dispute, automating the use of the bond token. --- package.json | 3 +- scripts/hubpool.ts | 274 +++++++++++++++++++++++++++++++++++++++++++ scripts/spokepool.ts | 100 +++------------- scripts/utils.ts | 114 ++++++++++++++++++ 4 files changed, 405 insertions(+), 86 deletions(-) create mode 100644 scripts/hubpool.ts diff --git a/package.json b/package.json index dc44b3d40..4f7456d2d 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,8 @@ "reinstall": "yarn clean && yarn install && yarn build", "update": "git pull && yarn reinstall && yarn version --non-interactive && git show --quiet", "relay": "HARDHAT_CONFIG=./dist/hardhat.config.js node ./dist/index.js --relayer", - "deposit": "yarn ts-node ./scripts/spokepool.ts deposit" + "deposit": "yarn ts-node ./scripts/spokepool.ts deposit", + "dispute": "yarn ts-node ./scripts/hubpool.ts dispute" }, "devDependencies": { "@nomiclabs/hardhat-ethers": "^2.2.3", diff --git a/scripts/hubpool.ts b/scripts/hubpool.ts new file mode 100644 index 000000000..d78a4c3b6 --- /dev/null +++ b/scripts/hubpool.ts @@ -0,0 +1,274 @@ +import minimist from "minimist"; +import { WETH9__factory as WETH9 } from "@across-protocol/contracts-v2"; +import { BigNumber, ethers, Wallet } from "ethers"; +import { config } from "dotenv"; +import { getNetworkName, getSigner } from "../src/utils"; +import * as utils from "./utils"; + +const { MaxUint256, One: bnOne } = ethers.constants; +const { formatEther, formatUnits } = ethers.utils; + +// https://nodejs.org/api/process.html#exit-codes +const NODE_SUCCESS = 0; +const NODE_INPUT_ERR = 9; +const NODE_APP_ERR = 127; // user-defined + +function bnMax(a: BigNumber, b: BigNumber): BigNumber { + const result = a.sub(b); + return result.isZero() || result.gt(0) ? a : b; +} + +async function dispute(args: Record, signer: Wallet): Promise { + const ethBuffer = "0.1"; // Spare ether required to pay for gas. + + const chainId = Number(args.chainId); + const { force, txnHash } = args; + + const network = getNetworkName(chainId); + const hubPool = await utils.getContract(chainId, "HubPool"); + signer = signer.connect(hubPool.provider); + const [bondTokenAddress, bondAmount, proposal, liveness, latestBlock] = await Promise.all([ + hubPool.bondToken(), + hubPool.bondAmount(), + hubPool.rootBundleProposal(), + hubPool.liveness(), + hubPool.provider.getBlock("latest"), + ]); + + const filter = hubPool.filters.ProposeRootBundle(); + const avgBlockTime = 12.5; // @todo import + const fromBlock = Math.floor(latestBlock.number - (liveness - avgBlockTime)); + const bondToken = WETH9.connect(bondTokenAddress, hubPool.provider); + const [bondBalance, decimals, symbol, allowance, proposals] = await Promise.all([ + bondToken.balanceOf(signer.address), + bondToken.decimals(), + bondToken.symbol(), + bondToken.allowance(signer.address, hubPool.address), + hubPool.queryFilter(filter, fromBlock, latestBlock.number), + ]); + + /* Resolve the existing proposal to dump its information. */ + const { poolRebalanceRoot, relayerRefundRoot, slowRelayRoot, challengePeriodEndTimestamp } = proposal; + const rootBundleProposal = proposals.find(({ args }) => { + return ( + args.poolRebalanceRoot === poolRebalanceRoot && + args.relayerRefundRoot === relayerRefundRoot && + args.slowRelayRoot === slowRelayRoot + ); + }); + const fields = { + address: bondToken.address, + symbol, + amount: formatUnits(bondAmount, decimals), + balance: formatUnits(bondBalance, decimals), + }; + + // @dev This works fine but is hackish. Might be nice to refactor later. + const proposalKeys = Object.keys(proposal).filter((key) => isNaN(Number(key))); + const _proposal = { + blockNumber: rootBundleProposal?.blockNumber, + transactionHash: rootBundleProposal?.transactionHash, + ...Object.fromEntries(proposalKeys.map((k) => [k, proposal[k]])), + }; + + const padLeft = [...Object.keys(fields), ...Object.keys(_proposal)].reduce( + (acc, cur) => (cur.length > acc ? cur.length : acc), + 0 + ); + console.log( + `${network} HubPool Dispute Bond:\n` + + Object.entries(fields) + .map(([k, v]) => `\t${k.padEnd(padLeft)} : ${v}`) + .join("\n") + + "\n" + ); + + if (rootBundleProposal === undefined) { + console.log( + `Warning: No matching root bundle proposal found between ${network} blocks ${fromBlock}, ${latestBlock.number}.` + ); + } else { + console.log( + `${network} Root Bundle Proposal:\n` + + Object.entries(_proposal) + .map(([k, v]) => `\t${k.padEnd(padLeft)} : ${v}`) + .join("\n") + + "\n" + ); + } + + if (allowance.lt(bondAmount)) { + console.log(`Approving ${network} HubPool @ ${hubPool.address} to transfer ${symbol}.`); + const approval = await bondToken.connect(signer).approve(hubPool.address, MaxUint256); + console.log(`Approval: ${approval.hash}...`); + await approval.wait(); + } + + if (bondBalance.lt(bondAmount)) { + const buffer = ethers.utils.parseEther(ethBuffer); + const ethBalance = await signer.getBalance(); + if (ethBalance.lt(bondAmount.add(buffer))) { + const minDeposit = bondAmount.add(buffer).sub(ethBalance).sub(bondBalance); + console.log( + `Cannot dispute - insufficient ${symbol} balance.` + ` Deposit at least ${formatUnits(minDeposit, 18)} ETH.` + ); + return false; + } + const depositAmount = bnMax(bondAmount.sub(bondBalance), bnOne); // Enforce minimum 1 Wei for test. + console.log(`Depositing ${formatEther(depositAmount)} @ ${bondToken.address}.`); + const deposit = await bondToken.connect(signer).deposit({ value: depositAmount }); + console.log(`Deposit: ${deposit.hash}...`); + await deposit.wait(); + } + if (latestBlock.timestamp >= challengePeriodEndTimestamp && !force) { + console.log("Nothing to dispute: no active propopsal."); + return txnHash === undefined; + } + + // The txn hash of the proposal must be supplied in order to dispute. + // If no hash was supplied, request the user to re-run with the applicable hash. + if (txnHash !== rootBundleProposal.transactionHash && !force) { + if (txnHash !== undefined) { + console.log(`Invalid proposal transaction hash supplied: ${txnHash}.`); + } + console.log( + "To dispute, re-run with the following transaction hash (WARNING: THIS *WILL* SUBMIT A DISPUTE):\n" + + `\n\t--txnHash ${rootBundleProposal.transactionHash}\n` + + "\nFor example:\n" + + `\n\tyarn dispute --txnHash ${rootBundleProposal.transactionHash}\n` + ); + return txnHash === undefined; + } + + const dispute = await hubPool.connect(signer).disputeRootBundle(); + console.log(`Disputing ${network} HubPool proposal: ${dispute.hash}.`); + await dispute.wait(); + console.log("Disputed HubPool proposal."); + + return true; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function search(args: Record, _signer: Wallet): Promise { + const eventName = args.event as string; + const fromBlock = Number(args.fromBlock) || undefined; + const toBlock = Number(args.toBlock) || undefined; + const chainId = Number(args.chainId); + + if (!isNaN(fromBlock) && !isNaN(toBlock) && toBlock < fromBlock) { + throw new Error(`Invalid block range: ${fromBlock}, ${toBlock}`); + } + + const [configStore, hubPool] = await Promise.all([ + utils.getContract(chainId, "ConfigStore"), + utils.getContract(chainId, "HubPool"), + ]); + + const filter = hubPool.filters[eventName]?.(); + if (filter === undefined) { + throw new Error(`Unrecognised HubPool event (${eventName})`); + } + + const events = await hubPool.queryFilter(filter, fromBlock, toBlock); + const CHAIN_ID_INDICES = ethers.utils.formatBytes32String("CHAIN_ID_INDICES"); + for (const { transactionHash, blockNumber, data, topics } of events) { + const [block, liveness, _chainIds] = await Promise.all([ + hubPool.provider.getBlock(blockNumber), + hubPool.liveness({ blockTag: blockNumber }), + configStore.globalConfig(CHAIN_ID_INDICES, { blockTag: blockNumber }), + ]); + + const DEFAULT_CHAIN_IDS = chainId === 1 ? utils.chains : utils.testChains; + const chainIds = _chainIds.length > 0 ? JSON.parse(_chainIds.replaceAll('"', "")) : DEFAULT_CHAIN_IDS; + + const args = hubPool.interface.parseLog({ data, topics }).args; + const eventArgs = Object.keys(args).filter((key) => isNaN(Number(key))); + const dateStr = new Date(Number(block.timestamp * 1000)).toUTCString(); + + const fields = { + blockNumber, + timestamp: `${block.timestamp} (${dateStr})`, + transactionHash, + liveness, + chainIds: chainIds.join(","), + ...Object.fromEntries(eventArgs.map((arg) => [arg, args[arg]])), + }; + const padLeft = Object.keys(fields).reduce((acc, cur) => (cur.length > acc ? cur.length : acc), 0); + console.log( + Object.entries(fields) + .map(([k, v]) => `${k.padEnd(padLeft)} : ${v}`) + .join("\n") + "\n" + ); + } + + return true; +} + +function usage(badInput?: string): boolean { + let usageStr = badInput ? `\nUnrecognized input: "${badInput}".\n\n` : ""; + const walletOpts = "mnemonic|privateKey"; + const runtimeArgs = { + dispute: ["--chainId", "[--txnHash ]"], + search: ["--chainId", "--event ", "[--fromBlock ]", "[--toBlock ]"], + }; + + usageStr += "Usage:\n"; + usageStr += Object.entries(runtimeArgs) + .map(([k, v]) => `\tyarn hubpool --wallet <${walletOpts}> ${k} ${v.join(" ")}`) + .join("\n"); + + console.log(usageStr); + return badInput === undefined ? false : true; +} + +async function run(argv: string[]): Promise { + const opts = { + string: ["chainId", "transactionHash", "event", "fromBlock", "toBlock", "wallet"], + boolean: ["force"], + default: { + chainId: 1, + event: "ProposeRootBundle", + wallet: "mnemonic", + force: false, + }, + alias: { + transactionHash: "txnHash", + }, + unknown: usage, + }; + const args = minimist(argv.slice(1), opts); + + config(); + + let signer: Wallet; + try { + signer = await getSigner({ keyType: args.wallet, cleanEnv: true }); + } catch (err) { + return usage(args.wallet) ? NODE_SUCCESS : NODE_INPUT_ERR; + } + + let result: boolean; + switch (argv[0]) { + case "dispute": + result = await dispute(args, signer); + break; + case "search": + result = await search(args, signer); + break; + default: + return usage() ? NODE_SUCCESS : NODE_INPUT_ERR; + } + + return result ? NODE_SUCCESS : NODE_APP_ERR; +} + +if (require.main === module) { + run(process.argv.slice(2)) + .then(async (result) => { + process.exitCode = result; + }) + .catch(async (error) => { + console.error("Process exited with", error); + process.exitCode = NODE_APP_ERR; + }); +} diff --git a/scripts/spokepool.ts b/scripts/spokepool.ts index c6e284328..c68c22ef7 100644 --- a/scripts/spokepool.ts +++ b/scripts/spokepool.ts @@ -1,35 +1,15 @@ -import assert from "assert"; -import * as contracts from "@across-protocol/contracts-v2"; +import minimist from "minimist"; +import { ExpandedERC20__factory as ERC20 } from "@across-protocol/contracts-v2"; import { LogDescription } from "@ethersproject/abi"; import { Contract, ethers, Wallet } from "ethers"; -import minimist from "minimist"; import { groupBy } from "lodash"; import { config } from "dotenv"; -import { getDeployedContract, getNetworkName, getNodeUrlList, getSigner, resolveTokenSymbols } from "../src/utils"; - -type ERC20 = { - address: string; - decimals: number; - symbol: string; -}; +import { getNetworkName, getSigner, resolveTokenSymbols } from "../src/utils"; +import * as utils from "./utils"; const { MaxUint256, Zero } = ethers.constants; const { isAddress } = ethers.utils; -const testChains = [5, 280]; -const chains = [1, 10, 137, 324, 8453, 42161]; - -function validateChainIds(chainIds: number[]): boolean { - const knownChainIds = [...chains, ...testChains]; - return chainIds.every((chainId) => { - const ok = knownChainIds.includes(chainId); - if (!ok) { - console.log(`Invalid chain ID: ${chainId}`); - } - return ok; - }); -} - function printDeposit(log: LogDescription): void { const { originChainId, originToken } = log.args; const eventArgs = Object.keys(log.args).filter((key) => isNaN(Number(key))); @@ -67,62 +47,12 @@ function printFill(log: LogDescription): void { ); } -/** - * Resolves an ERC20 type from a chain ID, and symbol or address. - * @param token The address or symbol of the token to resolve. - * @param chainId The chain ID to resolve the token on. - * @returns The ERC20 attributes of the token. - */ -function resolveToken(token: string, chainId: number): ERC20 { - // `token` may be an address or a symbol. Normalise it to a symbol for easy lookup. - const symbol = !isAddress(token) - ? token.toUpperCase() - : Object.values(contracts.TOKEN_SYMBOLS_MAP).find(({ addresses }) => addresses[chainId] === token)?.symbol; - - const _token = contracts.TOKEN_SYMBOLS_MAP[symbol]; - if (_token === undefined) { - throw new Error(`Token ${token} on chain ID ${chainId} unrecognised`); - } - - return { - address: _token.addresses[chainId], - decimals: _token.decimals, - symbol: _token.symbol, - }; -} - -function resolveHubChainId(spokeChainId: number): number { - if (chains.includes(spokeChainId)) { - return 1; - } - - assert(testChains.includes(spokeChainId), `Unsupported SpokePool chain ID: ${spokeChainId}`); - return 5; -} - -async function getHubPoolContract(chainId: number): Promise { - const contractName = "HubPool"; - const hubPoolChainId = resolveHubChainId(chainId); - - const hubPool = getDeployedContract(contractName, hubPoolChainId); - const provider = new ethers.providers.StaticJsonRpcProvider(getNodeUrlList(hubPoolChainId, 1)[0]); - return hubPool.connect(provider); -} - -async function getSpokePoolContract(chainId: number): Promise { - const hubPool = await getHubPoolContract(chainId); - const spokePoolAddr = (await hubPool.crossChainContracts(chainId))[1]; - - const contract = new Contract(spokePoolAddr, contracts.SpokePool__factory.abi); - return contract; -} - async function deposit(args: Record, signer: Wallet): Promise { const depositor = await signer.getAddress(); const [fromChainId, toChainId, baseAmount] = [Number(args.from), Number(args.to), Number(args.amount)]; const recipient = (args.recipient as string) ?? depositor; - if (!validateChainIds([fromChainId, toChainId])) { + if (!utils.validateChainIds([fromChainId, toChainId])) { usage(); // no return } const network = getNetworkName(fromChainId); @@ -132,15 +62,15 @@ async function deposit(args: Record, signer: Wallet): P usage(); // no return } - const token = resolveToken(args.token as string, fromChainId); + const token = utils.resolveToken(args.token as string, fromChainId); const tokenSymbol = token.symbol.toUpperCase(); const amount = ethers.utils.parseUnits(baseAmount.toString(), args.decimals ? 0 : token.decimals); - const provider = new ethers.providers.StaticJsonRpcProvider(getNodeUrlList(fromChainId, 1)[0]); + const provider = new ethers.providers.StaticJsonRpcProvider(utils.getProviderUrl(fromChainId)); signer = signer.connect(provider); - const spokePool = (await getSpokePoolContract(fromChainId)).connect(signer); + const spokePool = (await utils.getSpokePoolContract(fromChainId)).connect(signer); - const erc20 = new Contract(token.address, contracts.ExpandedERC20__factory.abi, signer); + const erc20 = new Contract(token.address, ERC20.abi, signer); const allowance = await erc20.allowance(depositor, spokePool.address); if (amount.gt(allowance)) { const approvalAmount = amount.mul(5); @@ -178,10 +108,10 @@ async function deposit(args: Record, signer: Wallet): P // eslint-disable-next-line @typescript-eslint/no-unused-vars async function dumpConfig(args: Record, _signer: Wallet): Promise { const chainId = Number(args.chainId); - const _spokePool = await getSpokePoolContract(chainId); + const _spokePool = await utils.getSpokePoolContract(chainId); - const hubChainId = resolveHubChainId(chainId); - const spokeProvider = new ethers.providers.StaticJsonRpcProvider(getNodeUrlList(chainId, 1)[0]); + const hubChainId = utils.resolveHubChainId(chainId); + const spokeProvider = new ethers.providers.StaticJsonRpcProvider(utils.getProviderUrl(chainId)); const spokePool = _spokePool.connect(spokeProvider); const [spokePoolChainId, hubPool, crossDomainAdmin, weth, _currentTime] = await Promise.all([ @@ -225,7 +155,7 @@ async function fetchTxn(args: Record, _signer: Wallet): const { txnHash } = args; const chainId = Number(args.chainId); - if (!validateChainIds([chainId])) { + if (!utils.validateChainIds([chainId])) { usage(); // no return } @@ -233,8 +163,8 @@ async function fetchTxn(args: Record, _signer: Wallet): throw new Error(`Missing or malformed transaction hash: ${txnHash}`); } - const provider = new ethers.providers.StaticJsonRpcProvider(getNodeUrlList(chainId, 1)[0]); - const spokePool = await getSpokePoolContract(chainId); + const provider = new ethers.providers.StaticJsonRpcProvider(utils.getProviderUrl(chainId)); + const spokePool = await utils.getSpokePoolContract(chainId); const txn = await provider.getTransactionReceipt(txnHash); const fundsDeposited = spokePool.interface.getEventTopic("FundsDeposited"); diff --git a/scripts/utils.ts b/scripts/utils.ts index 7a104888e..a673bdc23 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -1,4 +1,23 @@ +import assert from "assert"; +import { Contract, ethers, utils as ethersUtils } from "ethers"; import readline from "readline"; +import * as contracts from "@across-protocol/contracts-v2"; +import { getDeployedContract, getNodeUrlList } from "../src/utils"; + +type ERC20 = { + address: string; + decimals: number; + symbol: string; +}; + +export const testChains = [5, 280]; +export const chains = [1, 10, 137, 324, 8453, 42161]; + +// Public RPC endpoints to be used if preferred providers are not defined in the environment. +const fallbackProviders: { [chainId: number]: string } = { + 1: "https://eth.llamarpc.com", + 5: "https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161", +}; async function askQuestion(query: string) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -21,3 +40,98 @@ export async function askYesNoQuestion(query: string): Promise { } return askYesNoQuestion(query); } + +/** + * Resolves an ERC20 type from a chain ID, and symbol or address. + * @param token The address or symbol of the token to resolve. + * @param chainId The chain ID to resolve the token on. + * @returns The ERC20 attributes of the token. + */ +export function resolveToken(token: string, chainId: number): ERC20 { + // `token` may be an address or a symbol. Normalise it to a symbol for easy lookup. + const symbol = !ethersUtils.isAddress(token) + ? token.toUpperCase() + : Object.values(contracts.TOKEN_SYMBOLS_MAP).find(({ addresses }) => addresses[chainId] === token)?.symbol; + + const _token = contracts.TOKEN_SYMBOLS_MAP[symbol]; + if (_token === undefined) { + throw new Error(`Token ${token} on chain ID ${chainId} unrecognised`); + } + + return { + address: _token.addresses[chainId], + decimals: _token.decimals, + symbol: _token.symbol, + }; +} + +/** + * @description Verify that an array of chain IDs have known Across deployments. + * @dev This function does not detect if the test and production chain IDs have been mixed. + * @param chainIds Array of chain IDs to validate. + * @returns True if all chainIds are known. + */ +export function validateChainIds(chainIds: number[]): boolean { + const knownChainIds = [...chains, ...testChains]; + return chainIds.every((chainId) => { + const ok = knownChainIds.includes(chainId); + if (!ok) { + console.log(`Invalid chain ID: ${chainId}`); + } + return ok; + }); +} + +/** + * @description Resolve a default provider URL. + * @param chainId Chain ID for the provider to select. + * @returns URL of the provider endpoint. + */ +export function getProviderUrl(chainId: number): string { + try { + return getNodeUrlList(chainId, 1)[0]; + } catch { + return fallbackProviders[chainId]; + } +} + +/** + * @description For a SpokePool chain ID, resolve its corresponding HubPool chain ID. + * @param spokeChainId Chain ID of the SpokePool. + * @returns Chain ID for the corresponding HubPool. + */ +export function resolveHubChainId(spokeChainId: number): number { + if (chains.includes(spokeChainId)) { + return 1; + } + + assert(testChains.includes(spokeChainId), `Unsupported SpokePool chain ID: ${spokeChainId}`); + return 5; +} + +/** + * @description Instantiate an ethers Contract instance. + * @param chainId Chain ID for the contract deployment. + * @param contractName Name of the deployed contract. + * @returns ethers Contract instance. + */ +export async function getContract(chainId: number, contractName: string): Promise { + const hubPoolChainId = resolveHubChainId(chainId); + + const contract = getDeployedContract(contractName, hubPoolChainId); + const provider = new ethers.providers.StaticJsonRpcProvider(getProviderUrl(chainId)); + return contract.connect(provider); +} + +/** + * @description Instantiate an Across SpokePool contract instance. + * @param chainId Chain ID for the SpokePool deployment. + * @returns SpokePool contract instance. + */ +export async function getSpokePoolContract(chainId: number): Promise { + const hubPool = await getContract(chainId, "HubPool"); + const spokePoolAddr = (await hubPool.crossChainContracts(chainId))[1]; + + const contract = new Contract(spokePoolAddr, contracts.SpokePool__factory.abi); + return contract; +}