diff --git a/.changeset/wild-fans-repair.md b/.changeset/wild-fans-repair.md new file mode 100644 index 00000000000..971eeed669a --- /dev/null +++ b/.changeset/wild-fans-repair.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/cli': minor +--- + +Add 'submit' command to CLI. diff --git a/typescript/cli/.gitignore b/typescript/cli/.gitignore index e417d75c24f..4a0b6f51bf2 100644 --- a/typescript/cli/.gitignore +++ b/typescript/cli/.gitignore @@ -7,6 +7,7 @@ /artifacts /chains /deployments +/generated # Test artifacts /test-configs/**/addresses.yaml diff --git a/typescript/cli/cli.ts b/typescript/cli/cli.ts index 2328c253117..9ad52088393 100644 --- a/typescript/cli/cli.ts +++ b/typescript/cli/cli.ts @@ -22,6 +22,7 @@ import { import { registryCommand } from './src/commands/registry.js'; import { sendCommand } from './src/commands/send.js'; import { statusCommand } from './src/commands/status.js'; +import { submitCommand } from './src/commands/submit.js'; import { validatorCommand } from './src/commands/validator.js'; import { warpCommand } from './src/commands/warp.js'; import { contextMiddleware } from './src/context/context.js'; @@ -63,6 +64,7 @@ try { .command(statusCommand) .command(validatorCommand) .command(warpCommand) + .command(submitCommand) .version(VERSION) .demandCommand() .strict() diff --git a/typescript/cli/examples/submit/strategy/gnosis-ica-strategy.yaml b/typescript/cli/examples/submit/strategy/gnosis-ica-strategy.yaml new file mode 100644 index 00000000000..9ceeb3f8abc --- /dev/null +++ b/typescript/cli/examples/submit/strategy/gnosis-ica-strategy.yaml @@ -0,0 +1,11 @@ +chain: avalanche +submitter: + type: gnosisSafe + chain: avalanche + safeAddress: '0x7fd32493Ca3A38cDf78A4cb74F32f6292f822aBe' +transforms: + - type: interchainAccount + chain: ethereum + config: + origin: avalanche + owner: '0x16F4898F47c085C41d7Cc6b1dc72B91EA617dcBb' diff --git a/typescript/cli/examples/submit/strategy/gnosis-strategy.yaml b/typescript/cli/examples/submit/strategy/gnosis-strategy.yaml new file mode 100644 index 00000000000..d1f74fa0ad1 --- /dev/null +++ b/typescript/cli/examples/submit/strategy/gnosis-strategy.yaml @@ -0,0 +1,5 @@ +chain: avalanche +submitter: + type: gnosisSafe + chain: avalanche + safeAddress: '0x7fd32493Ca3A38cDf78A4cb74F32f6292f822aBe' diff --git a/typescript/cli/examples/submit/strategy/impersonated-account-strategy.yaml b/typescript/cli/examples/submit/strategy/impersonated-account-strategy.yaml new file mode 100644 index 00000000000..f1b63d824f1 --- /dev/null +++ b/typescript/cli/examples/submit/strategy/impersonated-account-strategy.yaml @@ -0,0 +1,4 @@ +chain: alfajores +submitter: + type: impersonatedAccount + userAddress: '0x16F4898F47c085C41d7Cc6b1dc72B91EA617dcBb' diff --git a/typescript/cli/examples/submit/strategy/json-rpc-strategy.yaml b/typescript/cli/examples/submit/strategy/json-rpc-strategy.yaml new file mode 100644 index 00000000000..35430964bb5 --- /dev/null +++ b/typescript/cli/examples/submit/strategy/json-rpc-strategy.yaml @@ -0,0 +1,3 @@ +chain: alfajores +submitter: + type: jsonRpc diff --git a/typescript/cli/examples/submit/transactions/alfajores-transactions.json b/typescript/cli/examples/submit/transactions/alfajores-transactions.json new file mode 100644 index 00000000000..b256b9886bb --- /dev/null +++ b/typescript/cli/examples/submit/transactions/alfajores-transactions.json @@ -0,0 +1,8 @@ +[ + { + "data": "0x0e72cc06000000000000000000000000744ad987ee7c65d3b3333bfc3e6ecbf963eb872a", + "to": "0x9a4a3124F2a86bB5BE46267De85D31762b7a05Fd", + "from": "0x16F4898F47c085C41d7Cc6b1dc72B91EA617dcBb", + "chainId": 44787 + } +] diff --git a/typescript/cli/examples/submit/transactions/avalanche-transactions.json b/typescript/cli/examples/submit/transactions/avalanche-transactions.json new file mode 100644 index 00000000000..bfb69e64c3b --- /dev/null +++ b/typescript/cli/examples/submit/transactions/avalanche-transactions.json @@ -0,0 +1,8 @@ +[ + { + "data": "0x0e72cc06000000000000000000000000744ad987ee7c65d3b3333bfc3e6ecbf963eb872a", + "to": "0x9a4a3124F2a86bB5BE46267De85D31762b7a05Fd", + "from": "0x16F4898F47c085C41d7Cc6b1dc72B91EA617dcBb", + "chainId": 43114 + } +] diff --git a/typescript/cli/src/commands/options.ts b/typescript/cli/src/commands/options.ts index 00124952e20..7b772b601d1 100644 --- a/typescript/cli/src/commands/options.ts +++ b/typescript/cli/src/commands/options.ts @@ -163,6 +163,20 @@ export const validatorCommandOption: Options = { demandOption: true, }; +export const transactionsCommandOption: Options = { + type: 'string', + description: 'The transaction input file path.', + alias: ['t', 'txs', 'txns'], + demandOption: true, +}; + +export const strategyCommandOption: Options = { + type: 'string', + description: 'The submission strategy input file path.', + alias: 's', + demandOption: true, +}; + export const addressCommandOption = ( description: string, demandOption = false, diff --git a/typescript/cli/src/commands/signCommands.ts b/typescript/cli/src/commands/signCommands.ts index 8df41a0a025..93b6f3015d7 100644 --- a/typescript/cli/src/commands/signCommands.ts +++ b/typescript/cli/src/commands/signCommands.ts @@ -1,7 +1,7 @@ // Commands that send tx and require a key to sign. // It's useful to have this listed here so the context // middleware can request keys up front when required. -export const SIGN_COMMANDS = ['deploy', 'send']; +export const SIGN_COMMANDS = ['deploy', 'send', 'submit']; export function isSignCommand(argv: any): boolean { return ( diff --git a/typescript/cli/src/commands/submit.ts b/typescript/cli/src/commands/submit.ts new file mode 100644 index 00000000000..78cd5c6bd41 --- /dev/null +++ b/typescript/cli/src/commands/submit.ts @@ -0,0 +1,42 @@ +import { runSubmit } from '../config/submit.js'; +import { CommandModuleWithWriteContext } from '../context/types.js'; +import { logBlue, logGray } from '../logger.js'; + +import { + dryRunCommandOption, + outputFileCommandOption, + strategyCommandOption, + transactionsCommandOption, +} from './options.js'; + +/** + * Submit command + */ +export const submitCommand: CommandModuleWithWriteContext<{ + transactions: string; + strategy: string; + 'dry-run': string; + receipts: string; +}> = { + command: 'submit', + describe: 'Submit transactions', + builder: { + transactions: transactionsCommandOption, + strategy: strategyCommandOption, + 'dry-run': dryRunCommandOption, + receipts: outputFileCommandOption('./generated/transactions/receipts.yaml'), + }, + handler: async ({ context, transactions, receipts }) => { + logGray(`Hyperlane Submit`); + logGray(`----------------`); + + await runSubmit({ + context, + transactionsFilepath: transactions, + receiptsFilepath: receipts, + }); + + logBlue(`✅ Submission complete`); + process.exit(0); + }, +}; diff --git a/typescript/cli/src/config/submit.ts b/typescript/cli/src/config/submit.ts new file mode 100644 index 00000000000..2a8656109b1 --- /dev/null +++ b/typescript/cli/src/config/submit.ts @@ -0,0 +1,75 @@ +import { stringify as yamlStringify } from 'yaml'; + +import { + PopulatedTransaction, + PopulatedTransactionSchema, +} from '@hyperlane-xyz/sdk'; +import { assert } from '@hyperlane-xyz/utils'; + +import { WriteCommandContext } from '../context/types.js'; +import { logGray, logRed } from '../logger.js'; +import { getSubmitterBuilder } from '../submit/submit.js'; +import { + indentYamlOrJson, + readYamlOrJson, + writeYamlOrJson, +} from '../utils/files.js'; + +export async function runSubmit({ + context, + transactionsFilepath, + receiptsFilepath, +}: { + context: WriteCommandContext; + transactionsFilepath: string; + receiptsFilepath: string; +}) { + const { submissionStrategy, chainMetadata, multiProvider, isDryRun } = + context; + + assert( + submissionStrategy, + 'Submission strategy required to submit transactions.\nPlease create a submission strategy, e.g. ./strategy.yaml.', + ); + + const chain = submissionStrategy.chain; + const protocol = chainMetadata[chain].protocol; + const submitterBuilder = await getSubmitterBuilder({ + submitterMetadata: submissionStrategy.submitter, + transformersMetadata: submissionStrategy.transforms ?? [], + multiProvider, + chain: submissionStrategy.chain, + isDryRun, + }); + const transactions = getTransactions(transactionsFilepath); + + try { + const transactionReceipts = await submitterBuilder.submit(...transactions); + if (transactionReceipts) { + logGray( + '🧾 Transaction receipts:\n\n', + indentYamlOrJson(yamlStringify(transactionReceipts, null, 2), 4), + ); + writeYamlOrJson(receiptsFilepath, transactionReceipts, 'yaml'); + } + } catch (error) { + logRed( + `⛔️ Failed to submit ${transactions.length} transactions:`, + JSON.stringify(error), + ); + throw new Error('Failed to submit transactions.'); + } +} + +function getTransactions(transactionsFilepath: string): PopulatedTransaction[] { + const transactionsFileContent = readYamlOrJson( + transactionsFilepath.trim(), + ); + assert( + transactionsFileContent, + 'Transactions required to submit transactions.\nPlease add a transactions file, e.g. ./transactions.json.', + ); + return transactionsFileContent.map((tx) => + PopulatedTransactionSchema.parse(tx), + ); +} diff --git a/typescript/cli/src/context/context.ts b/typescript/cli/src/context/context.ts index bc3258c3fe9..2b5c1352907 100644 --- a/typescript/cli/src/context/context.ts +++ b/typescript/cli/src/context/context.ts @@ -6,13 +6,20 @@ import { MergedRegistry, } from '@hyperlane-xyz/registry'; import { FileSystemRegistry } from '@hyperlane-xyz/registry/fs'; -import { ChainName, MultiProvider } from '@hyperlane-xyz/sdk'; +import { + ChainName, + MultiProvider, + SubmissionStrategy, + SubmissionStrategySchema, + TxSubmitterType, +} from '@hyperlane-xyz/sdk'; import { isHttpsUrl, isNullish, rootLogger } from '@hyperlane-xyz/utils'; import { isSignCommand } from '../commands/signCommands.js'; import { forkNetworkToMultiProvider, verifyAnvil } from '../deploy/dry-run.js'; import { logBlue } from '../logger.js'; import { runSingleChainSelectionStep } from '../utils/chains.js'; +import { readYamlOrJson } from '../utils/files.js'; import { getImpersonatedSigner, getSigner } from '../utils/keys.js'; import { @@ -22,7 +29,7 @@ import { } from './types.js'; export async function contextMiddleware(argv: Record) { - const isDryRun = !isNullish(argv.dryRun); + let isDryRun = !isNullish(argv.dryRun); const requiresKey = isSignCommand(argv); const settings: ContextSettings = { registryUri: argv.registry, @@ -36,6 +43,15 @@ export async function contextMiddleware(argv: Record) { throw new Error( "'--from-address' or '-f' should only be used for dry-runs", ); + if (argv.strategy) { + settings.submissionStrategy = getSubmissionStrategy(argv.strategy); + if ( + settings.submissionStrategy.submitter.type === + TxSubmitterType.IMPERSONATED_ACCOUNT + ) { + isDryRun = true; + } + } const context = isDryRun ? await getDryRunContext(settings, argv.dryRun) : await getContext(settings); @@ -52,6 +68,7 @@ export async function getContext({ key, requiresKey, skipConfirmation, + submissionStrategy, }: ContextSettings): Promise { const registry = getRegistry(registryUri, registryOverrideUri); @@ -68,6 +85,7 @@ export async function getContext({ key, signer, skipConfirmation: !!skipConfirmation, + submissionStrategy, } as CommandContext; } @@ -82,6 +100,7 @@ export async function getDryRunContext( key, fromAddress, skipConfirmation, + submissionStrategy, }: ContextSettings, chain?: ChainName, ): Promise { @@ -90,10 +109,12 @@ export async function getDryRunContext( if (!chain) { if (skipConfirmation) throw new Error('No chains provided'); - chain = await runSingleChainSelectionStep( - chainMetadata, - 'Select chain to dry-run against:', - ); + chain = submissionStrategy + ? submissionStrategy.chain + : await runSingleChainSelectionStep( + chainMetadata, + 'Select chain to dry-run against:', + ); } logBlue(`Dry-running against chain: ${chain}`); @@ -117,6 +138,7 @@ export async function getDryRunContext( skipConfirmation: !!skipConfirmation, isDryRun: true, dryRunChain: chain, + submissionStrategy, } as WriteCommandContext; } @@ -163,3 +185,17 @@ async function getMultiProvider(registry: IRegistry, signer?: ethers.Signer) { if (signer) multiProvider.setSharedSigner(signer); return multiProvider; } + +/** + * Retrieves a submission strategy from the provided filepath. + * @param submissionStrategyFilepath a filepath to the submission strategy file + * @returns a formatted submission strategy + */ +function getSubmissionStrategy( + submissionStrategyFilepath: string, +): SubmissionStrategy { + const submissionStrategyFileContent = readYamlOrJson( + submissionStrategyFilepath.trim(), + ); + return SubmissionStrategySchema.parse(submissionStrategyFileContent); +} diff --git a/typescript/cli/src/context/types.ts b/typescript/cli/src/context/types.ts index 80f3121fbfb..9edf93e7390 100644 --- a/typescript/cli/src/context/types.ts +++ b/typescript/cli/src/context/types.ts @@ -6,6 +6,7 @@ import type { ChainMap, ChainMetadata, MultiProvider, + SubmissionStrategy, } from '@hyperlane-xyz/sdk'; export interface ContextSettings { @@ -15,6 +16,7 @@ export interface ContextSettings { fromAddress?: string; requiresKey?: boolean; skipConfirmation?: boolean; + submissionStrategy?: SubmissionStrategy; } export interface CommandContext { @@ -22,6 +24,7 @@ export interface CommandContext { chainMetadata: ChainMap; multiProvider: MultiProvider; skipConfirmation: boolean; + submissionStrategy?: SubmissionStrategy; key?: string; signer?: ethers.Signer; } diff --git a/typescript/cli/src/submit/submit.ts b/typescript/cli/src/submit/submit.ts index fcde4afa4de..c0048612bf0 100644 --- a/typescript/cli/src/submit/submit.ts +++ b/typescript/cli/src/submit/submit.ts @@ -1,11 +1,11 @@ import { EV5GnosisSafeTxSubmitter, - EV5GnosisSafeTxSubmitterProps, EV5ImpersonatedAccountTxSubmitter, - EV5ImpersonatedAccountTxSubmitterProps, EV5InterchainAccountTxTransformer, EV5JsonRpcTxSubmitter, MultiProvider, + SubmitterMetadata, + TransformerMetadata, TxSubmitterBuilder, TxSubmitterInterface, TxSubmitterType, @@ -14,20 +14,18 @@ import { } from '@hyperlane-xyz/sdk'; import { ProtocolType } from '@hyperlane-xyz/utils'; -import { - SubmitterBuilderSettings, - SubmitterMetadata, - TransformerMetadata, -} from './types.js'; +import { SubmitterBuilderSettings } from './types.js'; export async function getSubmitterBuilder({ submitterMetadata, transformersMetadata, multiProvider, + isDryRun = false, }: SubmitterBuilderSettings): Promise> { const submitter = await getSubmitter( multiProvider, submitterMetadata, + isDryRun, ); const transformers = await getTransformers( multiProvider, @@ -40,32 +38,35 @@ export async function getSubmitterBuilder({ async function getSubmitter( multiProvider: MultiProvider, submitterMetadata: SubmitterMetadata, + isDryRun = false, ): Promise> { switch (submitterMetadata.type) { case TxSubmitterType.JSON_RPC: return new EV5JsonRpcTxSubmitter(multiProvider); case TxSubmitterType.IMPERSONATED_ACCOUNT: - return new EV5ImpersonatedAccountTxSubmitter( - multiProvider, - submitterMetadata.props as EV5ImpersonatedAccountTxSubmitterProps, - ); + if (!isDryRun) + throw new Error( + 'Impersonated account submitters may only be used during dry-runs.', + ); + return new EV5ImpersonatedAccountTxSubmitter(multiProvider, { + ...submitterMetadata, + }); case TxSubmitterType.GNOSIS_SAFE: - return new EV5GnosisSafeTxSubmitter( - multiProvider, - submitterMetadata.props as EV5GnosisSafeTxSubmitterProps, - ); + return new EV5GnosisSafeTxSubmitter(multiProvider, { + ...submitterMetadata, + }); default: - throw new Error(`Invalid TxSubmitterType: ${submitterMetadata.type}`); + throw new Error(`Invalid TxSubmitterType.`); } } async function getTransformers( multiProvider: MultiProvider, - metadata: TransformerMetadata[], + transformersMetadata: TransformerMetadata[], ): Promise[]> { return Promise.all( - metadata.map(({ type, props: settings }) => - getTransformer(multiProvider, { type, props: settings }), + transformersMetadata.map((transformerMetadata) => + getTransformer(multiProvider, transformerMetadata), ), ); } @@ -76,11 +77,10 @@ async function getTransformer( ): Promise> { switch (transformerMetadata.type) { case TxTransformerType.INTERCHAIN_ACCOUNT: - return new EV5InterchainAccountTxTransformer( - multiProvider, - transformerMetadata.props, - ); + return new EV5InterchainAccountTxTransformer(multiProvider, { + ...transformerMetadata, + }); default: - throw new Error(`Invalid TxTransformerType: ${transformerMetadata.type}`); + throw new Error('Invalid TxTransformerType.'); } } diff --git a/typescript/cli/src/submit/types.ts b/typescript/cli/src/submit/types.ts index d50a63e22c0..295a87cdb89 100644 --- a/typescript/cli/src/submit/types.ts +++ b/typescript/cli/src/submit/types.ts @@ -1,27 +1,14 @@ import type { - EV5GnosisSafeTxSubmitterProps, - EV5ImpersonatedAccountTxSubmitterProps, - EV5InterchainAccountTxTransformerProps, + ChainName, MultiProvider, - TxSubmitterType, - TxTransformerType, + SubmitterMetadata, + TransformerMetadata, } from '@hyperlane-xyz/sdk'; export interface SubmitterBuilderSettings { submitterMetadata: SubmitterMetadata; transformersMetadata: TransformerMetadata[]; multiProvider: MultiProvider; + chain: ChainName; + isDryRun?: boolean; } -export interface SubmitterMetadata { - type: TxSubmitterType; - props: SubmitterProps; -} -export interface TransformerMetadata { - type: TxTransformerType; - props: TransformerProps; -} - -type SubmitterProps = - | EV5ImpersonatedAccountTxSubmitterProps - | EV5GnosisSafeTxSubmitterProps; -type TransformerProps = EV5InterchainAccountTxTransformerProps; diff --git a/typescript/sdk/src/providers/transactions/submitter/schemas.ts b/typescript/sdk/src/providers/transactions/submitter/schemas.ts index 0c4185aaf90..9ec67f19b1f 100644 --- a/typescript/sdk/src/providers/transactions/submitter/schemas.ts +++ b/typescript/sdk/src/providers/transactions/submitter/schemas.ts @@ -9,14 +9,13 @@ import { export const SubmitterMetadataSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal(TxSubmitterType.JSON_RPC), - props: z.object({}).optional(), }), z.object({ type: z.literal(TxSubmitterType.IMPERSONATED_ACCOUNT), - props: EV5ImpersonatedAccountTxSubmitterPropsSchema, + ...EV5ImpersonatedAccountTxSubmitterPropsSchema.shape, }), z.object({ type: z.literal(TxSubmitterType.GNOSIS_SAFE), - props: EV5GnosisSafeTxSubmitterPropsSchema, + ...EV5GnosisSafeTxSubmitterPropsSchema.shape, }), ]); diff --git a/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.ts b/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.ts index 9a3e659fb9d..1c1a2debafa 100644 --- a/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.ts +++ b/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.ts @@ -1,7 +1,7 @@ import { ethers } from 'ethers'; import { Logger } from 'pino'; -import { assert, objKeys, rootLogger } from '@hyperlane-xyz/utils'; +import { assert, objMap, rootLogger } from '@hyperlane-xyz/utils'; import { InterchainAccount, @@ -57,17 +57,17 @@ export class EV5InterchainAccountTxTransformer ); const transformedTxs: ethers.PopulatedTransaction[] = []; - for (const txChain of objKeys(txChainsToInnerCalls)) { + objMap(txChainsToInnerCalls, async (destination, innerCalls) => { transformedTxs.push( await interchainAccountApp.getCallRemote({ chain: this.props.chain, - destination: txChain, - innerCalls: txChainsToInnerCalls[txChain], + destination, + innerCalls, config: this.props.config, hookMetadata: this.props.hookMetadata, }), ); - } + }); return transformedTxs; } diff --git a/typescript/sdk/src/providers/transactions/transformer/schemas.ts b/typescript/sdk/src/providers/transactions/transformer/schemas.ts index 14a5bb3580b..621e5d0c6ee 100644 --- a/typescript/sdk/src/providers/transactions/transformer/schemas.ts +++ b/typescript/sdk/src/providers/transactions/transformer/schemas.ts @@ -6,6 +6,6 @@ import { EV5InterchainAccountTxTransformerPropsSchema } from './ethersV5/schemas export const TransformerMetadataSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal(TxTransformerType.INTERCHAIN_ACCOUNT), - props: EV5InterchainAccountTxTransformerPropsSchema, + ...EV5InterchainAccountTxTransformerPropsSchema.shape, }), ]);