From 979bceb660f59a297dbb50472a145128e503c5f7 Mon Sep 17 00:00:00 2001 From: Paul Balaji <10051819+paulbalaji@users.noreply.github.com> Date: Wed, 20 Nov 2024 16:37:00 +0000 Subject: [PATCH] feat: script to print and execute pending safe txs (#4704) ### Description feat: script to print and optionally execute pending safe txs ### Drive-by changes add optional chain selection array to `withChains` and `withChainsRequired` ### Related issues na ### Backward compatibility yes ### Testing Example without execution: ![image](https://github.com/user-attachments/assets/2bac68c6-e8fe-4aec-a005-f255259658da) Example with execution: ![image](https://github.com/user-attachments/assets/6a1729d5-a17d-4e33-b7c9-b1b02bc07073) ![image](https://github.com/user-attachments/assets/80ab11bb-fbf9-4409-97f8-34899d057e16) --------- Signed-off-by: pbio <10051819+paulbalaji@users.noreply.github.com> --- typescript/infra/scripts/agent-utils.ts | 20 +- .../infra/scripts/safes/get-pending-txs.ts | 202 ++++++++++++++++++ typescript/infra/src/utils/safe.ts | 56 ++++- 3 files changed, 264 insertions(+), 14 deletions(-) create mode 100644 typescript/infra/scripts/safes/get-pending-txs.ts diff --git a/typescript/infra/scripts/agent-utils.ts b/typescript/infra/scripts/agent-utils.ts index 6f73005d3..105fdcf49 100644 --- a/typescript/infra/scripts/agent-utils.ts +++ b/typescript/infra/scripts/agent-utils.ts @@ -1,11 +1,7 @@ import path, { join } from 'path'; import yargs, { Argv } from 'yargs'; -import { - ChainAddresses, - IRegistry, - warpConfigToWarpAddresses, -} from '@hyperlane-xyz/registry'; +import { ChainAddresses, IRegistry } from '@hyperlane-xyz/registry'; import { ChainMap, ChainMetadata, @@ -157,20 +153,26 @@ export function withChain(args: Argv) { .alias('c', 'chain'); } -export function withChains(args: Argv) { +export function withChains(args: Argv, chainOptions?: ChainName[]) { return ( args .describe('chains', 'Set of chains to perform actions on.') .array('chains') - .choices('chains', getChains()) + .choices( + 'chains', + !chainOptions || chainOptions.length === 0 ? getChains() : chainOptions, + ) // Ensure chains are unique .coerce('chains', (chains: string[]) => Array.from(new Set(chains))) .alias('c', 'chains') ); } -export function withChainsRequired(args: Argv) { - return withChains(args).demandOption('chains'); +export function withChainsRequired( + args: Argv, + chainOptions?: ChainName[], +) { + return withChains(args, chainOptions).demandOption('chains'); } export function withWarpRouteId(args: Argv) { diff --git a/typescript/infra/scripts/safes/get-pending-txs.ts b/typescript/infra/scripts/safes/get-pending-txs.ts new file mode 100644 index 000000000..0c7fc7084 --- /dev/null +++ b/typescript/infra/scripts/safes/get-pending-txs.ts @@ -0,0 +1,202 @@ +import { confirm } from '@inquirer/prompts'; +import chalk from 'chalk'; +import yargs from 'yargs'; + +import { MultiProvider } from '@hyperlane-xyz/sdk'; + +import { Contexts } from '../../config/contexts.js'; +import { safes } from '../../config/environments/mainnet3/owners.js'; +import { Role } from '../../src/roles.js'; +import { executeTx, getSafeAndService } from '../../src/utils/safe.js'; +import { withChains } from '../agent-utils.js'; +import { getEnvironmentConfig } from '../core-utils.js'; + +export enum SafeTxStatus { + NO_CONFIRMATIONS = '🔴', + PENDING = '🟡', + ONE_AWAY = '🔵', + READY_TO_EXECUTE = '🟢', +} + +type SafeStatus = { + chain: string; + nonce: number; + submissionDate: string; + shortTxHash: string; + fullTxHash: string; + confs: number; + threshold: number; + status: string; +}; + +export async function getPendingTxsForChains( + chains: string[], + multiProvider: MultiProvider, +): Promise { + const txs: SafeStatus[] = []; + await Promise.all( + chains.map(async (chain) => { + if (!safes[chain]) { + console.error(chalk.red.bold(`No safe found for ${chain}`)); + return; + } + + if (chain === 'endurance') { + console.info( + chalk.gray.italic( + `Skipping chain ${chain} as it does not have a functional safe API`, + ), + ); + return; + } + + let safeSdk, safeService; + try { + ({ safeSdk, safeService } = await getSafeAndService( + chain, + multiProvider, + safes[chain], + )); + } catch (error) { + console.warn( + chalk.yellow( + `Skipping chain ${chain} as there was an error getting the safe service: ${error}`, + ), + ); + return; + } + + const threshold = await safeSdk.getThreshold(); + const pendingTxs = await safeService.getPendingTransactions(safes[chain]); + if (pendingTxs.results.length === 0) { + return; + } + + pendingTxs.results.forEach( + ({ nonce, submissionDate, safeTxHash, confirmations }) => { + const confs = confirmations?.length ?? 0; + const status = + confs >= threshold + ? SafeTxStatus.READY_TO_EXECUTE + : confs === 0 + ? SafeTxStatus.NO_CONFIRMATIONS + : threshold - confs + ? SafeTxStatus.ONE_AWAY + : SafeTxStatus.PENDING; + + txs.push({ + chain, + nonce, + submissionDate: new Date(submissionDate).toDateString(), + shortTxHash: `${safeTxHash.slice(0, 6)}...${safeTxHash.slice(-4)}`, + fullTxHash: safeTxHash, + confs, + threshold, + status, + }); + }, + ); + }), + ); + return txs.sort( + (a, b) => a.chain.localeCompare(b.chain) || a.nonce - b.nonce, + ); +} + +async function main() { + const safeChains = Object.keys(safes); + const { chains, fullTxHash, execute } = await withChains( + yargs(process.argv.slice(2)), + safeChains, + ) + .describe( + 'fullTxHash', + 'If enabled, include the full tx hash in the output', + ) + .boolean('fullTxHash') + .default('fullTxHash', false) + .describe( + 'execute', + 'If enabled, execute transactions that have enough confirmations', + ) + .boolean('execute') + .default('execute', false).argv; + + const chainsToCheck = chains || safeChains; + if (chainsToCheck.length === 0) { + console.error('No chains provided'); + process.exit(1); + } + + const envConfig = getEnvironmentConfig('mainnet3'); + const multiProvider = await envConfig.getMultiProvider( + Contexts.Hyperlane, + Role.Deployer, + true, + chainsToCheck, + ); + + const pendingTxs = await getPendingTxsForChains(chainsToCheck, multiProvider); + if (pendingTxs.length === 0) { + console.info(chalk.green('No pending transactions found!')); + process.exit(0); + } + console.table(pendingTxs, [ + 'chain', + 'nonce', + 'submissionDate', + fullTxHash ? 'fullTxHash' : 'shortTxHash', + 'confs', + 'threshold', + 'status', + ]); + + const executableTxs = pendingTxs.filter( + (tx) => tx.status === SafeTxStatus.READY_TO_EXECUTE, + ); + if ( + executableTxs.length === 0 || + !execute || + !(await confirm({ + message: 'Execute transactions?', + default: execute, + })) + ) { + console.info(chalk.green('No transactions to execute!')); + process.exit(0); + } else { + console.info(chalk.blueBright('Executing transactions...')); + } + + for (const tx of executableTxs) { + const confirmExecuteTx = await confirm({ + message: `Execute transaction ${tx.shortTxHash} on chain ${tx.chain}?`, + default: execute, + }); + if (confirmExecuteTx) { + console.log( + `Executing transaction ${tx.shortTxHash} on chain ${tx.chain}`, + ); + try { + await executeTx( + tx.chain, + multiProvider, + safes[tx.chain], + tx.fullTxHash, + ); + } catch (error) { + console.error(chalk.red(`Error executing transaction: ${error}`)); + return; + } + } + } + + process.exit(0); +} + +main() + .then() + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/typescript/infra/src/utils/safe.ts b/typescript/infra/src/utils/safe.ts index 397e746c0..08ae74aab 100644 --- a/typescript/infra/src/utils/safe.ts +++ b/typescript/infra/src/utils/safe.ts @@ -14,7 +14,7 @@ import { getSafe, getSafeService, } from '@hyperlane-xyz/sdk'; -import { Address, CallData, eqAddress } from '@hyperlane-xyz/utils'; +import { Address, CallData, eqAddress, retryAsync } from '@hyperlane-xyz/utils'; import safeSigners from '../../config/environments/mainnet3/safe/safeSigners.json' assert { type: 'json' }; import { AnnotatedCallData } from '../govern/HyperlaneAppGovernor.js'; @@ -24,10 +24,10 @@ export async function getSafeAndService( multiProvider: MultiProvider, safeAddress: Address, ) { - const safeSdk: Safe.default = await getSafe( - chain, - multiProvider, - safeAddress, + const safeSdk: Safe.default = await retryAsync( + () => getSafe(chain, multiProvider, safeAddress), + 5, + 1000, ); const safeService: SafeApiKit.default = getSafeService(chain, multiProvider); return { safeSdk, safeService }; @@ -41,6 +41,52 @@ export function createSafeTransactionData(call: CallData): MetaTransactionData { }; } +export async function executeTx( + chain: ChainNameOrId, + multiProvider: MultiProvider, + safeAddress: Address, + safeTxHash: string, +): Promise { + const { safeSdk, safeService } = await getSafeAndService( + chain, + multiProvider, + safeAddress, + ); + const safeTransaction = await safeService.getTransaction(safeTxHash); + if (!safeTransaction) { + throw new Error(`Failed to fetch transaction details for ${safeTxHash}`); + } + + // Throw if the safe doesn't have enough balance to cover the gas + let estimate; + try { + estimate = await safeService.estimateSafeTransaction( + safeAddress, + safeTransaction, + ); + } catch (error) { + throw new Error( + `Failed to estimate gas for Safe transaction ${safeTxHash} on chain ${chain}: ${error}`, + ); + } + const balance = await multiProvider + .getProvider(chain) + .getBalance(safeAddress); + if (balance.lt(estimate.safeTxGas)) { + throw new Error( + `Safe ${safeAddress} on ${chain} has insufficient balance (${balance.toString()}) for estimated gas (${ + estimate.safeTxGas + })`, + ); + } + + await safeSdk.executeTransaction(safeTransaction); + + console.log( + chalk.green.bold(`Executed transaction ${safeTxHash} on ${chain}`), + ); +} + export async function createSafeTransaction( safeSdk: Safe.default, safeService: SafeApiKit.default,