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>
pull/4864/merge
Paul Balaji 2 days ago committed by GitHub
parent 3ce70725ee
commit 979bceb660
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 20
      typescript/infra/scripts/agent-utils.ts
  2. 202
      typescript/infra/scripts/safes/get-pending-txs.ts
  3. 56
      typescript/infra/src/utils/safe.ts

@ -1,11 +1,7 @@
import path, { join } from 'path'; import path, { join } from 'path';
import yargs, { Argv } from 'yargs'; import yargs, { Argv } from 'yargs';
import { import { ChainAddresses, IRegistry } from '@hyperlane-xyz/registry';
ChainAddresses,
IRegistry,
warpConfigToWarpAddresses,
} from '@hyperlane-xyz/registry';
import { import {
ChainMap, ChainMap,
ChainMetadata, ChainMetadata,
@ -157,20 +153,26 @@ export function withChain<T>(args: Argv<T>) {
.alias('c', 'chain'); .alias('c', 'chain');
} }
export function withChains<T>(args: Argv<T>) { export function withChains<T>(args: Argv<T>, chainOptions?: ChainName[]) {
return ( return (
args args
.describe('chains', 'Set of chains to perform actions on.') .describe('chains', 'Set of chains to perform actions on.')
.array('chains') .array('chains')
.choices('chains', getChains()) .choices(
'chains',
!chainOptions || chainOptions.length === 0 ? getChains() : chainOptions,
)
// Ensure chains are unique // Ensure chains are unique
.coerce('chains', (chains: string[]) => Array.from(new Set(chains))) .coerce('chains', (chains: string[]) => Array.from(new Set(chains)))
.alias('c', 'chains') .alias('c', 'chains')
); );
} }
export function withChainsRequired<T>(args: Argv<T>) { export function withChainsRequired<T>(
return withChains(args).demandOption('chains'); args: Argv<T>,
chainOptions?: ChainName[],
) {
return withChains(args, chainOptions).demandOption('chains');
} }
export function withWarpRouteId<T>(args: Argv<T>) { export function withWarpRouteId<T>(args: Argv<T>) {

@ -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<SafeStatus[]> {
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);
});

@ -14,7 +14,7 @@ import {
getSafe, getSafe,
getSafeService, getSafeService,
} from '@hyperlane-xyz/sdk'; } 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 safeSigners from '../../config/environments/mainnet3/safe/safeSigners.json' assert { type: 'json' };
import { AnnotatedCallData } from '../govern/HyperlaneAppGovernor.js'; import { AnnotatedCallData } from '../govern/HyperlaneAppGovernor.js';
@ -24,10 +24,10 @@ export async function getSafeAndService(
multiProvider: MultiProvider, multiProvider: MultiProvider,
safeAddress: Address, safeAddress: Address,
) { ) {
const safeSdk: Safe.default = await getSafe( const safeSdk: Safe.default = await retryAsync(
chain, () => getSafe(chain, multiProvider, safeAddress),
multiProvider, 5,
safeAddress, 1000,
); );
const safeService: SafeApiKit.default = getSafeService(chain, multiProvider); const safeService: SafeApiKit.default = getSafeService(chain, multiProvider);
return { safeSdk, safeService }; 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<void> {
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( export async function createSafeTransaction(
safeSdk: Safe.default, safeSdk: Safe.default,
safeService: SafeApiKit.default, safeService: SafeApiKit.default,

Loading…
Cancel
Save