diff --git a/.changeset/chilly-balloons-rule.md b/.changeset/chilly-balloons-rule.md new file mode 100644 index 0000000000..b339b75699 --- /dev/null +++ b/.changeset/chilly-balloons-rule.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/utils': minor +--- + +Added `isPrivateKeyEvm` function for validating EVM private keys diff --git a/.changeset/spicy-gifts-hear.md b/.changeset/spicy-gifts-hear.md new file mode 100644 index 0000000000..37d4efa28d --- /dev/null +++ b/.changeset/spicy-gifts-hear.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/cli': minor +--- + +Added strategy management CLI commands and MultiProtocolSigner implementation for flexible cross-chain signer configuration and management diff --git a/typescript/cli/cli.ts b/typescript/cli/cli.ts index 77be0b86f8..45cad33bb8 100644 --- a/typescript/cli/cli.ts +++ b/typescript/cli/cli.ts @@ -19,15 +19,17 @@ import { overrideRegistryUriCommandOption, registryUriCommandOption, skipConfirmationOption, + strategyCommandOption, } from './src/commands/options.js'; import { registryCommand } from './src/commands/registry.js'; import { relayerCommand } from './src/commands/relayer.js'; import { sendCommand } from './src/commands/send.js'; import { statusCommand } from './src/commands/status.js'; +import { strategyCommand } from './src/commands/strategy.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'; +import { contextMiddleware, signerMiddleware } from './src/context/context.js'; import { configureLogger, errorRed } from './src/logger.js'; import { checkVersion } from './src/utils/version-check.js'; import { VERSION } from './src/version.js'; @@ -49,12 +51,14 @@ try { .option('key', keyCommandOption) .option('disableProxy', disableProxyCommandOption) .option('yes', skipConfirmationOption) + .option('strategy', strategyCommandOption) .global(['log', 'verbosity', 'registry', 'overrides', 'yes']) .middleware([ (argv) => { configureLogger(argv.log as LogFormat, argv.verbosity as LogLevel); }, contextMiddleware, + signerMiddleware, ]) .command(avsCommand) .command(configCommand) @@ -66,6 +70,7 @@ try { .command(relayerCommand) .command(sendCommand) .command(statusCommand) + .command(strategyCommand) .command(submitCommand) .command(validatorCommand) .command(warpCommand) diff --git a/typescript/cli/src/commands/config.ts b/typescript/cli/src/commands/config.ts index e72b72452a..4a5c6b580a 100644 --- a/typescript/cli/src/commands/config.ts +++ b/typescript/cli/src/commands/config.ts @@ -3,6 +3,7 @@ import { CommandModule } from 'yargs'; import { readChainConfigs } from '../config/chain.js'; import { readIsmConfig } from '../config/ism.js'; import { readMultisigConfig } from '../config/multisig.js'; +import { readChainSubmissionStrategyConfig } from '../config/strategy.js'; import { readWarpRouteDeployConfig } from '../config/warp.js'; import { CommandModuleWithContext } from '../context/types.js'; import { log, logGreen } from '../logger.js'; @@ -31,6 +32,7 @@ const validateCommand: CommandModule = { .command(validateChainCommand) .command(validateIsmCommand) .command(validateIsmAdvancedCommand) + .command(validateStrategyCommand) .command(validateWarpCommand) .version(false) .demandCommand(), @@ -76,6 +78,19 @@ const validateIsmAdvancedCommand: CommandModuleWithContext<{ path: string }> = { }, }; +const validateStrategyCommand: CommandModuleWithContext<{ path: string }> = { + command: 'strategy', + describe: 'Validates a Strategy config file', + builder: { + path: inputFileCommandOption(), + }, + handler: async ({ path }) => { + await readChainSubmissionStrategyConfig(path); + logGreen('Config is valid'); + process.exit(0); + }, +}; + const validateWarpCommand: CommandModuleWithContext<{ path: string }> = { command: 'warp', describe: 'Validate a Warp Route deployment config file', diff --git a/typescript/cli/src/commands/options.ts b/typescript/cli/src/commands/options.ts index f23194c804..baf0fa8472 100644 --- a/typescript/cli/src/commands/options.ts +++ b/typescript/cli/src/commands/options.ts @@ -95,6 +95,7 @@ export const DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH = './configs/warp-route-deployment.yaml'; export const DEFAULT_CORE_DEPLOYMENT_CONFIG_PATH = './configs/core-config.yaml'; +export const DEFAULT_STRATEGY_CONFIG_PATH = `${os.homedir()}/.hyperlane/strategies/default-strategy.yaml`; export const warpDeploymentConfigCommandOption: Options = { type: 'string', @@ -196,8 +197,8 @@ export const transactionsCommandOption: Options = { export const strategyCommandOption: Options = { type: 'string', description: 'The submission strategy input file path.', - alias: 's', - demandOption: true, + alias: ['s', 'strategy'], + demandOption: false, }; export const addressCommandOption = ( diff --git a/typescript/cli/src/commands/signCommands.ts b/typescript/cli/src/commands/signCommands.ts index 37d83096a0..8cfa6d4c1a 100644 --- a/typescript/cli/src/commands/signCommands.ts +++ b/typescript/cli/src/commands/signCommands.ts @@ -1,11 +1,36 @@ // 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', 'status', 'submit', 'relayer']; +export const SIGN_COMMANDS = [ + 'apply', + 'deploy', + 'send', + 'status', + 'submit', + 'relayer', +]; export function isSignCommand(argv: any): boolean { + //TODO: fix reading and checking warp without signer, and remove this + const temporarySignCommandsCheck = + argv._[0] === 'warp' && (argv._[1] === 'read' || argv._[1] === 'check'); return ( SIGN_COMMANDS.includes(argv._[0]) || - (argv._.length > 1 && SIGN_COMMANDS.includes(argv._[1])) + (argv._.length > 1 && SIGN_COMMANDS.includes(argv._[1])) || + temporarySignCommandsCheck ); } + +export enum CommandType { + WARP_DEPLOY = 'warp:deploy', + WARP_SEND = 'warp:send', + WARP_APPLY = 'warp:apply', + WARP_READ = 'warp:read', + WARP_CHECK = 'warp:check', + SEND_MESSAGE = 'send:message', + AGENT_KURTOSIS = 'deploy:kurtosis-agents', + STATUS = 'status:', + SUBMIT = 'submit:', + RELAYER = 'relayer:', + CORE_APPLY = 'core:apply', +} diff --git a/typescript/cli/src/commands/strategy.ts b/typescript/cli/src/commands/strategy.ts new file mode 100644 index 0000000000..414a3d48ee --- /dev/null +++ b/typescript/cli/src/commands/strategy.ts @@ -0,0 +1,70 @@ +import { stringify as yamlStringify } from 'yaml'; +import { CommandModule } from 'yargs'; + +import { + createStrategyConfig, + readChainSubmissionStrategyConfig, +} from '../config/strategy.js'; +import { CommandModuleWithWriteContext } from '../context/types.js'; +import { log, logCommandHeader } from '../logger.js'; +import { indentYamlOrJson } from '../utils/files.js'; +import { maskSensitiveData } from '../utils/output.js'; + +import { + DEFAULT_STRATEGY_CONFIG_PATH, + outputFileCommandOption, + strategyCommandOption, +} from './options.js'; + +/** + * Parent command + */ +export const strategyCommand: CommandModule = { + command: 'strategy', + describe: 'Manage Hyperlane deployment strategies', + builder: (yargs) => + yargs.command(init).command(read).version(false).demandCommand(), + handler: () => log('Command required'), +}; + +export const init: CommandModuleWithWriteContext<{ + out: string; +}> = { + command: 'init', + describe: 'Creates strategy configuration', + builder: { + out: outputFileCommandOption(DEFAULT_STRATEGY_CONFIG_PATH), + }, + handler: async ({ context, out }) => { + logCommandHeader(`Hyperlane Strategy Init`); + + await createStrategyConfig({ + context, + outPath: out, + }); + process.exit(0); + }, +}; + +export const read: CommandModuleWithWriteContext<{ + strategy: string; +}> = { + command: 'read', + describe: 'Reads strategy configuration', + builder: { + strategy: { + ...strategyCommandOption, + demandOption: true, + default: DEFAULT_STRATEGY_CONFIG_PATH, + }, + }, + handler: async ({ strategy: strategyUrl }) => { + logCommandHeader(`Hyperlane Strategy Read`); + + const strategy = await readChainSubmissionStrategyConfig(strategyUrl); + const maskedConfig = maskSensitiveData(strategy); + log(indentYamlOrJson(yamlStringify(maskedConfig, null, 2), 4)); + + process.exit(0); + }, +}; diff --git a/typescript/cli/src/config/core.ts b/typescript/cli/src/config/core.ts index c7161f6b4b..5b4395c951 100644 --- a/typescript/cli/src/config/core.ts +++ b/typescript/cli/src/config/core.ts @@ -39,7 +39,7 @@ export async function createCoreDeployConfig({ logBlue('Creating a new core deployment config...'); const owner = await detectAndConfirmOrPrompt( - async () => context.signer?.getAddress(), + async () => context.signerAddress, ENTER_DESIRED_VALUE_MSG, 'owner address', SIGNER_PROMPT_LABEL, @@ -64,7 +64,7 @@ export async function createCoreDeployConfig({ }); proxyAdmin = { owner: await detectAndConfirmOrPrompt( - async () => context.signer?.getAddress(), + async () => context.signerAddress, ENTER_DESIRED_VALUE_MSG, 'ProxyAdmin owner address', SIGNER_PROMPT_LABEL, diff --git a/typescript/cli/src/config/hooks.ts b/typescript/cli/src/config/hooks.ts index e8df64dc00..5ad005dc24 100644 --- a/typescript/cli/src/config/hooks.ts +++ b/typescript/cli/src/config/hooks.ts @@ -243,10 +243,10 @@ async function getOwnerAndBeneficiary( advanced: boolean, ) { const unnormalizedOwner = - !advanced && context.signer - ? await context.signer.getAddress() + !advanced && context.signerAddress + ? context.signerAddress : await detectAndConfirmOrPrompt( - async () => context.signer?.getAddress(), + async () => context.signerAddress, `For ${module}, enter`, 'owner address', 'signer', diff --git a/typescript/cli/src/config/ism.ts b/typescript/cli/src/config/ism.ts index f7f6bab9ad..81440e96eb 100644 --- a/typescript/cli/src/config/ism.ts +++ b/typescript/cli/src/config/ism.ts @@ -163,10 +163,10 @@ export const createTrustedRelayerConfig = callWithConfigCreationLogs( advanced: boolean = false, ): Promise => { const relayer = - !advanced && context.signer - ? await context.signer.getAddress() + !advanced && context.signerAddress + ? context.signerAddress : await detectAndConfirmOrPrompt( - async () => context.signer?.getAddress(), + async () => context.signerAddress, 'For trusted relayer ISM, enter', 'relayer address', 'signer', diff --git a/typescript/cli/src/config/strategy.ts b/typescript/cli/src/config/strategy.ts new file mode 100644 index 0000000000..f57c7d3378 --- /dev/null +++ b/typescript/cli/src/config/strategy.ts @@ -0,0 +1,186 @@ +import { confirm, input, password, select } from '@inquirer/prompts'; +import { Wallet } from 'ethers'; +import { stringify as yamlStringify } from 'yaml'; + +import { + ChainSubmissionStrategy, + ChainSubmissionStrategySchema, + TxSubmitterType, +} from '@hyperlane-xyz/sdk'; +import { + ProtocolType, + assert, + errorToString, + isAddress, + isPrivateKeyEvm, +} from '@hyperlane-xyz/utils'; + +import { CommandContext } from '../context/types.js'; +import { errorRed, log, logBlue, logGreen, logRed } from '../logger.js'; +import { runSingleChainSelectionStep } from '../utils/chains.js'; +import { + indentYamlOrJson, + isFile, + readYamlOrJson, + writeYamlOrJson, +} from '../utils/files.js'; +import { maskSensitiveData } from '../utils/output.js'; + +/** + * Reads and validates a chain submission strategy configuration from a file + */ +export async function readChainSubmissionStrategyConfig( + filePath: string, +): Promise { + log(`Reading submission strategy in ${filePath}`); + try { + const strategyConfig = readYamlOrJson(filePath); + + const parseResult = ChainSubmissionStrategySchema.parse(strategyConfig); + + return parseResult; + } catch (error) { + logRed(`⛔️ Error reading strategy config:`, errorToString(error)); + throw error; // Re-throw to let caller handle the error + } +} + +/** + * Safely reads chain submission strategy config, returns empty object if any errors occur + */ +export async function safeReadChainSubmissionStrategyConfig( + filePath: string, +): Promise { + try { + const trimmedFilePath = filePath.trim(); + if (!isFile(trimmedFilePath)) { + logBlue(`File ${trimmedFilePath} does not exist, returning empty config`); + return {}; + } + return await readChainSubmissionStrategyConfig(trimmedFilePath); + } catch (error) { + logRed( + `Failed to read strategy config, defaulting to empty config:`, + errorToString(error), + ); + return {}; + } +} + +export async function createStrategyConfig({ + context, + outPath, +}: { + context: CommandContext; + outPath: string; +}) { + let strategy: ChainSubmissionStrategy; + try { + const strategyObj = await readYamlOrJson(outPath); + strategy = ChainSubmissionStrategySchema.parse(strategyObj); + } catch { + strategy = writeYamlOrJson(outPath, {}, 'yaml'); + } + + const chain = await runSingleChainSelectionStep(context.chainMetadata); + const chainProtocol = context.chainMetadata[chain].protocol; + + if ( + !context.skipConfirmation && + strategy && + Object.prototype.hasOwnProperty.call(strategy, chain) + ) { + const isConfirmed = await confirm({ + message: `Default strategy for chain ${chain} already exists. Are you sure you want to overwrite existing strategy config?`, + default: false, + }); + + assert(isConfirmed, 'Strategy initialization cancelled by user.'); + } + + const isEthereum = chainProtocol === ProtocolType.Ethereum; + const submitterType = isEthereum + ? await select({ + message: 'Select the submitter type', + choices: Object.values(TxSubmitterType).map((value) => ({ + name: value, + value: value, + })), + }) + : TxSubmitterType.JSON_RPC; // Do other non-evm chains support gnosis and account impersonation? + + const submitter: Record = { type: submitterType }; + + switch (submitterType) { + case TxSubmitterType.JSON_RPC: + submitter.privateKey = await password({ + message: 'Enter the private key for JSON-RPC submission:', + validate: (pk) => (isEthereum ? isPrivateKeyEvm(pk) : true), + }); + + submitter.userAddress = isEthereum + ? await new Wallet(submitter.privateKey).getAddress() + : await input({ + message: 'Enter the user address for JSON-RPC submission:', + }); + + submitter.chain = chain; + break; + + case TxSubmitterType.IMPERSONATED_ACCOUNT: + submitter.userAddress = await input({ + message: 'Enter the user address to impersonate', + validate: (address) => + isAddress(address) ? true : 'Invalid Ethereum address', + }); + assert( + submitter.userAddress, + 'User address is required for impersonated account', + ); + break; + + case TxSubmitterType.GNOSIS_SAFE: + case TxSubmitterType.GNOSIS_TX_BUILDER: + submitter.safeAddress = await input({ + message: 'Enter the Safe address', + validate: (address) => + isAddress(address) ? true : 'Invalid Safe address', + }); + + submitter.chain = chain; + + if (submitterType === TxSubmitterType.GNOSIS_TX_BUILDER) { + submitter.version = await input({ + message: 'Enter the Safe version (default: 1.0)', + default: '1.0', + }); + } + break; + + default: + throw new Error(`Unsupported submitter type: ${submitterType}`); + } + + const strategyResult: ChainSubmissionStrategy = { + ...strategy, + [chain]: { + submitter: submitter as ChainSubmissionStrategy[string]['submitter'], + }, + }; + + try { + const strategyConfig = ChainSubmissionStrategySchema.parse(strategyResult); + logBlue(`Strategy configuration is valid. Writing to file ${outPath}:\n`); + + const maskedConfig = maskSensitiveData(strategyConfig); + log(indentYamlOrJson(yamlStringify(maskedConfig, null, 2), 4)); + + writeYamlOrJson(outPath, strategyConfig); + logGreen('✅ Successfully created a new strategy configuration.'); + } catch { + // don't log error since it may contain sensitive data + errorRed( + `The strategy configuration is invalid. Please review the submitter settings.`, + ); + } +} diff --git a/typescript/cli/src/config/warp.ts b/typescript/cli/src/config/warp.ts index 1174d0156b..f31069e091 100644 --- a/typescript/cli/src/config/warp.ts +++ b/typescript/cli/src/config/warp.ts @@ -82,7 +82,7 @@ async function fillDefaults( let owner = config.owner; if (!owner) { owner = - (await context.signer?.getAddress()) ?? + context.signerAddress ?? (await context.multiProvider.getSignerAddress(chain)); } return { @@ -122,13 +122,6 @@ export async function createWarpRouteDeployConfig({ }) { logBlue('Creating a new warp route deployment config...'); - const owner = await detectAndConfirmOrPrompt( - async () => context.signer?.getAddress(), - 'Enter the desired', - 'owner address', - 'signer', - ); - const warpChains = await runMultiChainSelectionStep({ chainMetadata: context.chainMetadata, message: 'Select chains to connect', @@ -142,6 +135,12 @@ export async function createWarpRouteDeployConfig({ let typeChoices = TYPE_CHOICES; for (const chain of warpChains) { logBlue(`${chain}: Configuring warp route...`); + const owner = await detectAndConfirmOrPrompt( + async () => context.signerAddress, + 'Enter the desired', + 'owner address', + 'signer', + ); // default to the mailbox from the registry and if not found ask to the user to submit one const chainAddresses = await context.registry.getChainAddresses(chain); diff --git a/typescript/cli/src/context/context.ts b/typescript/cli/src/context/context.ts index f9dfb34ab9..570b233cde 100644 --- a/typescript/cli/src/context/context.ts +++ b/typescript/cli/src/context/context.ts @@ -1,5 +1,5 @@ import { confirm } from '@inquirer/prompts'; -import { ethers } from 'ethers'; +import { Signer, ethers } from 'ethers'; import { DEFAULT_GITHUB_REGISTRY, @@ -16,7 +16,9 @@ import { } from '@hyperlane-xyz/sdk'; import { isHttpsUrl, isNullish, rootLogger } from '@hyperlane-xyz/utils'; +import { DEFAULT_STRATEGY_CONFIG_PATH } from '../commands/options.js'; import { isSignCommand } from '../commands/signCommands.js'; +import { safeReadChainSubmissionStrategyConfig } from '../config/strategy.js'; import { PROXY_DEPLOYED_URL } from '../consts.js'; import { forkNetworkToMultiProvider, verifyAnvil } from '../deploy/dry-run.js'; import { logBlue } from '../logger.js'; @@ -24,6 +26,8 @@ import { runSingleChainSelectionStep } from '../utils/chains.js'; import { detectAndConfirmOrPrompt } from '../utils/input.js'; import { getImpersonatedSigner, getSigner } from '../utils/keys.js'; +import { ChainResolverFactory } from './strategies/chain/ChainResolverFactory.js'; +import { MultiProtocolSignerManager } from './strategies/signer/MultiProtocolSignerManager.js'; import { CommandContext, ContextSettings, @@ -41,6 +45,7 @@ export async function contextMiddleware(argv: Record) { requiresKey, disableProxy: argv.disableProxy, skipConfirmation: argv.yes, + strategyPath: argv.strategy, }; if (!isDryRun && settings.fromAddress) throw new Error( @@ -52,6 +57,44 @@ export async function contextMiddleware(argv: Record) { argv.context = context; } +export async function signerMiddleware(argv: Record) { + const { key, requiresKey, multiProvider, strategyPath } = argv.context; + + if (!requiresKey) return argv; + + const strategyConfig = await safeReadChainSubmissionStrategyConfig( + strategyPath ?? DEFAULT_STRATEGY_CONFIG_PATH, + ); + + /** + * Intercepts Hyperlane command to determine chains. + */ + const chainStrategy = ChainResolverFactory.getStrategy(argv); + + /** + * Resolves chains based on the chain strategy. + */ + const chains = await chainStrategy.resolveChains(argv); + + /** + * Extracts signer config + */ + const multiProtocolSigner = new MultiProtocolSignerManager( + strategyConfig, + chains, + multiProvider, + { key }, + ); + + /** + * @notice Attaches signers to MultiProvider and assigns it to argv.multiProvider + */ + argv.multiProvider = await multiProtocolSigner.getMultiProvider(); + argv.multiProtocolSigner = multiProtocolSigner; + + return argv; +} + /** * Retrieves context for the user-selected command * @returns context for the current command @@ -66,19 +109,24 @@ export async function getContext({ }: ContextSettings): Promise { const registry = getRegistry(registryUri, registryOverrideUri, !disableProxy); - let signer: ethers.Wallet | undefined = undefined; - if (key || requiresKey) { + //Just for backward compatibility + let signerAddress: string | undefined = undefined; + if (key) { + let signer: Signer; ({ key, signer } = await getSigner({ key, skipConfirmation })); + signerAddress = await signer.getAddress(); } - const multiProvider = await getMultiProvider(registry, signer); + + const multiProvider = await getMultiProvider(registry); return { registry, + requiresKey, chainMetadata: multiProvider.metadata, multiProvider, key, - signer, skipConfirmation: !!skipConfirmation, + signerAddress, } as CommandContext; } diff --git a/typescript/cli/src/context/strategies/chain/ChainResolverFactory.ts b/typescript/cli/src/context/strategies/chain/ChainResolverFactory.ts new file mode 100644 index 0000000000..e417ba27b3 --- /dev/null +++ b/typescript/cli/src/context/strategies/chain/ChainResolverFactory.ts @@ -0,0 +1,36 @@ +import { CommandType } from '../../../commands/signCommands.js'; + +import { MultiChainResolver } from './MultiChainResolver.js'; +import { SingleChainResolver } from './SingleChainResolver.js'; +import { ChainResolver } from './types.js'; + +/** + * @class ChainResolverFactory + * @description Intercepts commands to determine the appropriate chain resolver strategy based on command type. + */ +export class ChainResolverFactory { + private static strategyMap: Map ChainResolver> = new Map([ + [CommandType.WARP_DEPLOY, () => MultiChainResolver.forWarpRouteConfig()], + [CommandType.WARP_SEND, () => MultiChainResolver.forOriginDestination()], + [CommandType.WARP_APPLY, () => MultiChainResolver.forWarpRouteConfig()], + [CommandType.WARP_READ, () => MultiChainResolver.forWarpCoreConfig()], + [CommandType.WARP_CHECK, () => MultiChainResolver.forWarpCoreConfig()], + [CommandType.SEND_MESSAGE, () => MultiChainResolver.forOriginDestination()], + [CommandType.AGENT_KURTOSIS, () => MultiChainResolver.forAgentKurtosis()], + [CommandType.STATUS, () => MultiChainResolver.forOriginDestination()], + [CommandType.SUBMIT, () => MultiChainResolver.forStrategyConfig()], + [CommandType.RELAYER, () => MultiChainResolver.forRelayer()], + [CommandType.CORE_APPLY, () => MultiChainResolver.forCoreApply()], + ]); + + /** + * @param argv - Command line arguments. + * @returns ChainResolver - The appropriate chain resolver strategy based on the command type. + */ + static getStrategy(argv: Record): ChainResolver { + const commandKey = `${argv._[0]}:${argv._[1] || ''}`.trim() as CommandType; + const createStrategy = + this.strategyMap.get(commandKey) || (() => new SingleChainResolver()); + return createStrategy(); + } +} diff --git a/typescript/cli/src/context/strategies/chain/MultiChainResolver.ts b/typescript/cli/src/context/strategies/chain/MultiChainResolver.ts new file mode 100644 index 0000000000..64f3257520 --- /dev/null +++ b/typescript/cli/src/context/strategies/chain/MultiChainResolver.ts @@ -0,0 +1,249 @@ +import { + ChainMap, + ChainName, + DeployedCoreAddresses, + DeployedCoreAddressesSchema, + EvmCoreModule, +} from '@hyperlane-xyz/sdk'; +import { assert } from '@hyperlane-xyz/utils'; + +import { DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH } from '../../../commands/options.js'; +import { readCoreDeployConfigs } from '../../../config/core.js'; +import { readChainSubmissionStrategyConfig } from '../../../config/strategy.js'; +import { log } from '../../../logger.js'; +import { + extractChainsFromObj, + runMultiChainSelectionStep, + runSingleChainSelectionStep, +} from '../../../utils/chains.js'; +import { + isFile, + readYamlOrJson, + runFileSelectionStep, +} from '../../../utils/files.js'; +import { getWarpCoreConfigOrExit } from '../../../utils/warp.js'; + +import { ChainResolver } from './types.js'; + +enum ChainSelectionMode { + ORIGIN_DESTINATION, + AGENT_KURTOSIS, + WARP_CONFIG, + WARP_READ, + STRATEGY, + RELAYER, + CORE_APPLY, +} + +// This class could be broken down into multiple strategies + +/** + * @title MultiChainResolver + * @notice Resolves chains based on the specified selection mode. + */ +export class MultiChainResolver implements ChainResolver { + constructor(private mode: ChainSelectionMode) {} + + async resolveChains(argv: ChainMap): Promise { + switch (this.mode) { + case ChainSelectionMode.WARP_CONFIG: + return this.resolveWarpRouteConfigChains(argv); + case ChainSelectionMode.WARP_READ: + return this.resolveWarpCoreConfigChains(argv); + case ChainSelectionMode.AGENT_KURTOSIS: + return this.resolveAgentChains(argv); + case ChainSelectionMode.STRATEGY: + return this.resolveStrategyChains(argv); + case ChainSelectionMode.RELAYER: + return this.resolveRelayerChains(argv); + case ChainSelectionMode.CORE_APPLY: + return this.resolveCoreApplyChains(argv); + case ChainSelectionMode.ORIGIN_DESTINATION: + default: + return this.resolveOriginDestinationChains(argv); + } + } + + private async resolveWarpRouteConfigChains( + argv: Record, + ): Promise { + argv.config ||= DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH; + argv.context.chains = await this.getWarpRouteConfigChains( + argv.config.trim(), + argv.skipConfirmation, + ); + return argv.context.chains; + } + + private async resolveWarpCoreConfigChains( + argv: Record, + ): Promise { + if (argv.symbol || argv.warp) { + const warpCoreConfig = await getWarpCoreConfigOrExit({ + context: argv.context, + warp: argv.warp, + symbol: argv.symbol, + }); + argv.context.warpCoreConfig = warpCoreConfig; + const chains = extractChainsFromObj(warpCoreConfig); + return chains; + } else if (argv.chain) { + return [argv.chain]; + } else { + throw new Error( + `Please specify either a symbol, chain and address or warp file`, + ); + } + } + + private async resolveAgentChains( + argv: Record, + ): Promise { + const { chainMetadata } = argv.context; + argv.origin = + argv.origin ?? + (await runSingleChainSelectionStep( + chainMetadata, + 'Select the origin chain', + )); + + if (!argv.targets) { + const selectedRelayChains = await runMultiChainSelectionStep({ + chainMetadata: chainMetadata, + message: 'Select chains to relay between', + requireNumber: 2, + }); + argv.targets = selectedRelayChains.join(','); + } + + return [argv.origin, ...argv.targets]; + } + + private async resolveOriginDestinationChains( + argv: Record, + ): Promise { + const { chainMetadata } = argv.context; + + argv.origin = + argv.origin ?? + (await runSingleChainSelectionStep( + chainMetadata, + 'Select the origin chain', + )); + + argv.destination = + argv.destination ?? + (await runSingleChainSelectionStep( + chainMetadata, + 'Select the destination chain', + )); + + return [argv.origin, argv.destination]; + } + + private async resolveStrategyChains( + argv: Record, + ): Promise { + const strategy = await readChainSubmissionStrategyConfig(argv.strategy); + return extractChainsFromObj(strategy); + } + + private async resolveRelayerChains( + argv: Record, + ): Promise { + return argv.chains.split(',').map((item: string) => item.trim()); + } + + private async getWarpRouteConfigChains( + configPath: string, + skipConfirmation: boolean, + ): Promise { + if (!configPath || !isFile(configPath)) { + assert(!skipConfirmation, 'Warp route deployment config is required'); + configPath = await runFileSelectionStep( + './configs', + 'Warp route deployment config', + 'warp', + ); + } else { + log(`Using warp route deployment config at ${configPath}`); + } + + // Alternative to readWarpRouteDeployConfig that doesn't use context for signer and zod validation + const warpRouteConfig = (await readYamlOrJson(configPath)) as Record< + string, + any + >; + + const chains = Object.keys(warpRouteConfig) as ChainName[]; + assert( + chains.length !== 0, + 'No chains found in warp route deployment config', + ); + + return chains; + } + + private async resolveCoreApplyChains( + argv: Record, + ): Promise { + try { + const config = readCoreDeployConfigs(argv.config); + + if (!config?.interchainAccountRouter) { + return [argv.chain]; + } + + const addresses = await argv.context.registry.getChainAddresses( + argv.chain, + ); + const coreAddresses = DeployedCoreAddressesSchema.parse( + addresses, + ) as DeployedCoreAddresses; + + const evmCoreModule = new EvmCoreModule(argv.context.multiProvider, { + chain: argv.chain, + config, + addresses: coreAddresses, + }); + + const transactions = await evmCoreModule.update(config); + + return Array.from(new Set(transactions.map((tx) => tx.chainId))).map( + (chainId) => argv.context.multiProvider.getChainName(chainId), + ); + } catch (error) { + throw new Error(`Failed to resolve core apply chains`, { + cause: error, + }); + } + } + + static forAgentKurtosis(): MultiChainResolver { + return new MultiChainResolver(ChainSelectionMode.AGENT_KURTOSIS); + } + + static forOriginDestination(): MultiChainResolver { + return new MultiChainResolver(ChainSelectionMode.ORIGIN_DESTINATION); + } + + static forRelayer(): MultiChainResolver { + return new MultiChainResolver(ChainSelectionMode.RELAYER); + } + + static forStrategyConfig(): MultiChainResolver { + return new MultiChainResolver(ChainSelectionMode.STRATEGY); + } + + static forWarpRouteConfig(): MultiChainResolver { + return new MultiChainResolver(ChainSelectionMode.WARP_CONFIG); + } + + static forWarpCoreConfig(): MultiChainResolver { + return new MultiChainResolver(ChainSelectionMode.WARP_READ); + } + + static forCoreApply(): MultiChainResolver { + return new MultiChainResolver(ChainSelectionMode.CORE_APPLY); + } +} diff --git a/typescript/cli/src/context/strategies/chain/SingleChainResolver.ts b/typescript/cli/src/context/strategies/chain/SingleChainResolver.ts new file mode 100644 index 0000000000..8dddaf3c4a --- /dev/null +++ b/typescript/cli/src/context/strategies/chain/SingleChainResolver.ts @@ -0,0 +1,25 @@ +import { ChainMap, ChainName } from '@hyperlane-xyz/sdk'; + +import { runSingleChainSelectionStep } from '../../../utils/chains.js'; + +import { ChainResolver } from './types.js'; + +/** + * @title SingleChainResolver + * @notice Strategy implementation for managing single-chain operations + * @dev Primarily used for operations like 'core:apply' and 'warp:read' + */ +export class SingleChainResolver implements ChainResolver { + /** + * @notice Determines the chain to be used for signing operations + * @dev Either uses the chain specified in argv or prompts for interactive selection + */ + async resolveChains(argv: ChainMap): Promise { + argv.chain ||= await runSingleChainSelectionStep( + argv.context.chainMetadata, + 'Select chain to connect:', + ); + + return [argv.chain]; // Explicitly return as single-item array + } +} diff --git a/typescript/cli/src/context/strategies/chain/types.ts b/typescript/cli/src/context/strategies/chain/types.ts new file mode 100644 index 0000000000..9318bed8c2 --- /dev/null +++ b/typescript/cli/src/context/strategies/chain/types.ts @@ -0,0 +1,10 @@ +import { ChainMap, ChainName } from '@hyperlane-xyz/sdk'; + +export interface ChainResolver { + /** + * Determines the chains to be used for signing + * @param argv Command arguments + * @returns Array of chain names + */ + resolveChains(argv: ChainMap): Promise; +} diff --git a/typescript/cli/src/context/strategies/signer/BaseMultiProtocolSigner.ts b/typescript/cli/src/context/strategies/signer/BaseMultiProtocolSigner.ts new file mode 100644 index 0000000000..b91242b42d --- /dev/null +++ b/typescript/cli/src/context/strategies/signer/BaseMultiProtocolSigner.ts @@ -0,0 +1,22 @@ +import { Signer } from 'ethers'; + +import { ChainName, ChainSubmissionStrategy } from '@hyperlane-xyz/sdk'; +import { Address } from '@hyperlane-xyz/utils'; + +export interface SignerConfig { + privateKey: string; + address?: Address; // For chains like StarkNet that require address + extraParams?: Record; // For any additional chain-specific params +} + +export interface IMultiProtocolSigner { + getSignerConfig(chain: ChainName): Promise | SignerConfig; + getSigner(config: SignerConfig): Signer; +} + +export abstract class BaseMultiProtocolSigner implements IMultiProtocolSigner { + constructor(protected config: ChainSubmissionStrategy) {} + + abstract getSignerConfig(chain: ChainName): Promise; + abstract getSigner(config: SignerConfig): Signer; +} diff --git a/typescript/cli/src/context/strategies/signer/MultiProtocolSignerFactory.ts b/typescript/cli/src/context/strategies/signer/MultiProtocolSignerFactory.ts new file mode 100644 index 0000000000..030f11b5f4 --- /dev/null +++ b/typescript/cli/src/context/strategies/signer/MultiProtocolSignerFactory.ts @@ -0,0 +1,79 @@ +import { password } from '@inquirer/prompts'; +import { Signer, Wallet } from 'ethers'; + +import { + ChainName, + ChainSubmissionStrategy, + ChainTechnicalStack, + MultiProvider, + TxSubmitterType, +} from '@hyperlane-xyz/sdk'; +import { ProtocolType } from '@hyperlane-xyz/utils'; + +import { + BaseMultiProtocolSigner, + IMultiProtocolSigner, + SignerConfig, +} from './BaseMultiProtocolSigner.js'; + +export class MultiProtocolSignerFactory { + static getSignerStrategy( + chain: ChainName, + strategyConfig: ChainSubmissionStrategy, + multiProvider: MultiProvider, + ): IMultiProtocolSigner { + const { protocol, technicalStack } = multiProvider.getChainMetadata(chain); + + switch (protocol) { + case ProtocolType.Ethereum: + if (technicalStack === ChainTechnicalStack.ZkSync) + return new ZKSyncSignerStrategy(strategyConfig); + return new EthereumSignerStrategy(strategyConfig); + default: + throw new Error(`Unsupported protocol: ${protocol}`); + } + } +} + +class EthereumSignerStrategy extends BaseMultiProtocolSigner { + async getSignerConfig(chain: ChainName): Promise { + const submitter = this.config[chain]?.submitter as { + type: TxSubmitterType.JSON_RPC; + privateKey?: string; + }; + + const privateKey = + submitter?.privateKey ?? + (await password({ + message: `Please enter the private key for chain ${chain}`, + })); + + return { privateKey }; + } + + getSigner(config: SignerConfig): Signer { + return new Wallet(config.privateKey); + } +} + +// 99% overlap with EthereumSignerStrategy for the sake of keeping MultiProtocolSignerFactory clean +// TODO: import ZKSync signer +class ZKSyncSignerStrategy extends BaseMultiProtocolSigner { + async getSignerConfig(chain: ChainName): Promise { + const submitter = this.config[chain]?.submitter as { + privateKey?: string; + }; + + const privateKey = + submitter?.privateKey ?? + (await password({ + message: `Please enter the private key for chain ${chain}`, + })); + + return { privateKey }; + } + + getSigner(config: SignerConfig): Signer { + return new Wallet(config.privateKey); + } +} diff --git a/typescript/cli/src/context/strategies/signer/MultiProtocolSignerManager.ts b/typescript/cli/src/context/strategies/signer/MultiProtocolSignerManager.ts new file mode 100644 index 0000000000..12f9c0f819 --- /dev/null +++ b/typescript/cli/src/context/strategies/signer/MultiProtocolSignerManager.ts @@ -0,0 +1,153 @@ +import { Signer } from 'ethers'; +import { Logger } from 'pino'; + +import { + ChainName, + ChainSubmissionStrategy, + MultiProvider, +} from '@hyperlane-xyz/sdk'; +import { assert, rootLogger } from '@hyperlane-xyz/utils'; + +import { ENV } from '../../../utils/env.js'; + +import { IMultiProtocolSigner } from './BaseMultiProtocolSigner.js'; +import { MultiProtocolSignerFactory } from './MultiProtocolSignerFactory.js'; + +export interface MultiProtocolSignerOptions { + logger?: Logger; + key?: string; +} + +/** + * @title MultiProtocolSignerManager + * @dev Context manager for signers across multiple protocols + */ +export class MultiProtocolSignerManager { + protected readonly signerStrategies: Map; + protected readonly signers: Map; + public readonly logger: Logger; + + constructor( + protected readonly submissionStrategy: ChainSubmissionStrategy, + protected readonly chains: ChainName[], + protected readonly multiProvider: MultiProvider, + protected readonly options: MultiProtocolSignerOptions = {}, + ) { + this.logger = + options?.logger || + rootLogger.child({ + module: 'MultiProtocolSignerManager', + }); + this.signerStrategies = new Map(); + this.signers = new Map(); + this.initializeStrategies(); + } + + /** + * @notice Sets up chain-specific signer strategies + */ + protected initializeStrategies(): void { + for (const chain of this.chains) { + const strategy = MultiProtocolSignerFactory.getSignerStrategy( + chain, + this.submissionStrategy, + this.multiProvider, + ); + this.signerStrategies.set(chain, strategy); + } + } + + /** + * @dev Configures signers for EVM chains in MultiProvider + */ + async getMultiProvider(): Promise { + for (const chain of this.chains) { + const signer = await this.initSigner(chain); + this.multiProvider.setSigner(chain, signer); + } + + return this.multiProvider; + } + + /** + * @notice Creates signer for specific chain + */ + async initSigner(chain: ChainName): Promise { + const { privateKey } = await this.resolveConfig(chain); + + const signerStrategy = this.signerStrategies.get(chain); + assert(signerStrategy, `No signer strategy found for chain ${chain}`); + + return signerStrategy.getSigner({ privateKey }); + } + + /** + * @notice Creates signers for all chains + */ + async initAllSigners(): Promise { + const signerConfigs = await this.resolveAllConfigs(); + + for (const { chain, privateKey } of signerConfigs) { + const signerStrategy = this.signerStrategies.get(chain); + if (signerStrategy) { + this.signers.set(chain, signerStrategy.getSigner({ privateKey })); + } + } + + return this.signers; + } + + /** + * @notice Resolves all chain configurations + */ + private async resolveAllConfigs(): Promise< + Array<{ chain: ChainName; privateKey: string }> + > { + return Promise.all(this.chains.map((chain) => this.resolveConfig(chain))); + } + + /** + * @notice Resolves single chain configuration + */ + private async resolveConfig( + chain: ChainName, + ): Promise<{ chain: ChainName; privateKey: string }> { + const signerStrategy = this.signerStrategies.get(chain); + assert(signerStrategy, `No signer strategy found for chain ${chain}`); + + let privateKey: string; + + if (this.options.key) { + this.logger.info( + `Using private key passed via CLI --key flag for chain ${chain}`, + ); + privateKey = this.options.key; + } else if (ENV.HYP_KEY) { + this.logger.info(`Using private key from .env for chain ${chain}`); + privateKey = ENV.HYP_KEY; + } else { + privateKey = await this.extractPrivateKey(chain, signerStrategy); + } + + return { chain, privateKey }; + } + + /** + * @notice Gets private key from strategy + */ + private async extractPrivateKey( + chain: ChainName, + signerStrategy: IMultiProtocolSigner, + ): Promise { + const strategyConfig = await signerStrategy.getSignerConfig(chain); + assert( + strategyConfig.privateKey, + `No private key found for chain ${chain}`, + ); + + this.logger.info( + `Extracting private key from strategy config/user prompt for chain ${chain}`, + ); + return strategyConfig.privateKey; + } +} diff --git a/typescript/cli/src/context/types.ts b/typescript/cli/src/context/types.ts index 6c3a17c5ff..c320ff3cac 100644 --- a/typescript/cli/src/context/types.ts +++ b/typescript/cli/src/context/types.ts @@ -6,6 +6,7 @@ import type { ChainMap, ChainMetadata, MultiProvider, + WarpCoreConfig, } from '@hyperlane-xyz/sdk'; export interface ContextSettings { @@ -16,6 +17,7 @@ export interface ContextSettings { requiresKey?: boolean; disableProxy?: boolean; skipConfirmation?: boolean; + strategyPath?: string; } export interface CommandContext { @@ -24,7 +26,10 @@ export interface CommandContext { multiProvider: MultiProvider; skipConfirmation: boolean; key?: string; - signer?: ethers.Signer; + // just for evm chains backward compatibility + signerAddress?: string; + warpCoreConfig?: WarpCoreConfig; + strategyPath?: string; } export interface WriteCommandContext extends CommandContext { diff --git a/typescript/cli/src/deploy/agent.ts b/typescript/cli/src/deploy/agent.ts index ca490fc5fb..a36955a3f3 100644 --- a/typescript/cli/src/deploy/agent.ts +++ b/typescript/cli/src/deploy/agent.ts @@ -21,6 +21,7 @@ export async function runKurtosisAgentDeploy({ relayChains?: string; agentConfigurationPath?: string; }) { + // Future works: decide what to do with this, since its handled in MultiChainResolver - AGENT_KURTOSIS mode if (!originChain) { originChain = await runSingleChainSelectionStep( context.chainMetadata, diff --git a/typescript/cli/src/deploy/core.ts b/typescript/cli/src/deploy/core.ts index 06d08b13cd..7ce8a0247c 100644 --- a/typescript/cli/src/deploy/core.ts +++ b/typescript/cli/src/deploy/core.ts @@ -43,7 +43,6 @@ export async function runCoreDeploy(params: DeployParams) { let chain = params.chain; const { - signer, isDryRun, chainMetadata, dryRunChain, @@ -62,13 +61,14 @@ export async function runCoreDeploy(params: DeployParams) { 'Select chain to connect:', ); } - let apiKeys: ChainMap = {}; if (!skipConfirmation) apiKeys = await requestAndSaveApiKeys([chain], chainMetadata, registry); + const signer = multiProvider.getSigner(chain); + const deploymentParams: DeployParams = { - context, + context: { ...context, signer }, chain, config, }; diff --git a/typescript/cli/src/deploy/utils.ts b/typescript/cli/src/deploy/utils.ts index 125e7b1e77..f5ac01a175 100644 --- a/typescript/cli/src/deploy/utils.ts +++ b/typescript/cli/src/deploy/utils.ts @@ -41,7 +41,7 @@ export async function runPreflightChecksForChains({ chainsToGasCheck?: ChainName[]; }) { log('Running pre-flight checks for chains...'); - const { signer, multiProvider } = context; + const { multiProvider } = context; if (!chains?.length) throw new Error('Empty chain selection'); for (const chain of chains) { @@ -49,15 +49,14 @@ export async function runPreflightChecksForChains({ if (!metadata) throw new Error(`No chain config found for ${chain}`); if (metadata.protocol !== ProtocolType.Ethereum) throw new Error('Only Ethereum chains are supported for now'); + const signer = multiProvider.getSigner(chain); + assertSigner(signer); + logGreen(`✅ ${chain} signer is valid`); } logGreen('✅ Chains are valid'); - assertSigner(signer); - logGreen('✅ Signer is valid'); - await nativeBalancesAreSufficient( multiProvider, - signer, chainsToGasCheck ?? chains, minGas, ); @@ -70,8 +69,13 @@ export async function runDeployPlanStep({ context: WriteCommandContext; chain: ChainName; }) { - const { signer, chainMetadata: chainMetadataMap, skipConfirmation } = context; - const address = await signer.getAddress(); + const { + chainMetadata: chainMetadataMap, + multiProvider, + skipConfirmation, + } = context; + + const address = await multiProvider.getSigner(chain).getAddress(); logBlue('\nDeployment plan'); logGray('==============='); @@ -124,7 +128,7 @@ export function isZODISMConfig(filepath: string): boolean { export async function prepareDeploy( context: WriteCommandContext, - userAddress: Address, + userAddress: Address | null, chains: ChainName[], ): Promise> { const { multiProvider, isDryRun } = context; @@ -134,7 +138,9 @@ export async function prepareDeploy( const provider = isDryRun ? getLocalProvider(ENV.ANVIL_IP_ADDR, ENV.ANVIL_PORT) : multiProvider.getProvider(chain); - const currentBalance = await provider.getBalance(userAddress); + const address = + userAddress ?? (await multiProvider.getSigner(chain).getAddress()); + const currentBalance = await provider.getBalance(address); initialBalances[chain] = currentBalance; }), ); @@ -145,7 +151,7 @@ export async function completeDeploy( context: WriteCommandContext, command: string, initialBalances: Record, - userAddress: Address, + userAddress: Address | null, chains: ChainName[], ) { const { multiProvider, isDryRun } = context; @@ -154,7 +160,9 @@ export async function completeDeploy( const provider = isDryRun ? getLocalProvider(ENV.ANVIL_IP_ADDR, ENV.ANVIL_PORT) : multiProvider.getProvider(chain); - const currentBalance = await provider.getBalance(userAddress); + const address = + userAddress ?? (await multiProvider.getSigner(chain).getAddress()); + const currentBalance = await provider.getBalance(address); const balanceDelta = initialBalances[chain].sub(currentBalance); if (isDryRun && balanceDelta.lt(0)) break; logPink( diff --git a/typescript/cli/src/deploy/warp.ts b/typescript/cli/src/deploy/warp.ts index 018244700a..e94bd709da 100644 --- a/typescript/cli/src/deploy/warp.ts +++ b/typescript/cli/src/deploy/warp.ts @@ -102,7 +102,7 @@ export async function runWarpRouteDeploy({ context: WriteCommandContext; warpRouteDeploymentConfigPath?: string; }) { - const { signer, skipConfirmation, chainMetadata, registry } = context; + const { skipConfirmation, chainMetadata, registry } = context; if ( !warpRouteDeploymentConfigPath || @@ -149,13 +149,8 @@ export async function runWarpRouteDeploy({ minGas: MINIMUM_WARP_DEPLOY_GAS, }); - const userAddress = await signer.getAddress(); + const initialBalances = await prepareDeploy(context, null, ethereumChains); - const initialBalances = await prepareDeploy( - context, - userAddress, - ethereumChains, - ); const deployedContracts = await executeDeploy(deploymentParams, apiKeys); const warpCoreConfig = await getWarpCoreConfig( @@ -165,13 +160,7 @@ export async function runWarpRouteDeploy({ await writeDeploymentArtifacts(warpCoreConfig, context); - await completeDeploy( - context, - 'warp', - initialBalances, - userAddress, - ethereumChains, - ); + await completeDeploy(context, 'warp', initialBalances, null, ethereumChains!); } async function runDeployPlanStep({ context, warpDeployConfig }: DeployParams) { diff --git a/typescript/cli/src/read/warp.ts b/typescript/cli/src/read/warp.ts index bd5d01e95e..169593c5e9 100644 --- a/typescript/cli/src/read/warp.ts +++ b/typescript/cli/src/read/warp.ts @@ -34,11 +34,13 @@ export async function runWarpRouteRead({ let addresses: ChainMap; if (symbol || warp) { - const warpCoreConfig = await getWarpCoreConfigOrExit({ - context, - warp, - symbol, - }); + const warpCoreConfig = + context.warpCoreConfig ?? // this case is be handled by MultiChainHandler.forWarpCoreConfig() interceptor + (await getWarpCoreConfigOrExit({ + context, + warp, + symbol, + })); // TODO: merge with XERC20TokenAdapter and WarpRouteReader const xerc20Limits = await Promise.all( diff --git a/typescript/cli/src/send/transfer.ts b/typescript/cli/src/send/transfer.ts index a89eb6aa99..2929b09c6e 100644 --- a/typescript/cli/src/send/transfer.ts +++ b/typescript/cli/src/send/transfer.ts @@ -40,8 +40,8 @@ export async function sendTestTransfer({ }: { context: WriteCommandContext; warpCoreConfig: WarpCoreConfig; - origin?: ChainName; - destination?: ChainName; + origin?: ChainName; // resolved in signerMiddleware + destination?: ChainName; // resolved in signerMiddleware amount: string; recipient?: string; timeoutSec: number; @@ -106,10 +106,15 @@ async function executeDelivery({ skipWaitForDelivery: boolean; selfRelay?: boolean; }) { - const { signer, multiProvider, registry } = context; + const { multiProvider, registry } = context; + const signer = multiProvider.getSigner(origin); + const recipientSigner = multiProvider.getSigner(destination); + + const recipientAddress = await recipientSigner.getAddress(); const signerAddress = await signer.getAddress(); - recipient ||= signerAddress; + + recipient ||= recipientAddress; const chainAddresses = await registry.getAddresses(); @@ -136,12 +141,11 @@ async function executeDelivery({ token = warpCore.findToken(origin, routerAddress)!; } - const senderAddress = await signer.getAddress(); const errors = await warpCore.validateTransfer({ originTokenAmount: token.amount(amount), destination, - recipient: recipient ?? senderAddress, - sender: senderAddress, + recipient, + sender: signerAddress, }); if (errors) { logRed('Error validating transfer', JSON.stringify(errors)); @@ -152,8 +156,8 @@ async function executeDelivery({ const transferTxs = await warpCore.getTransferRemoteTxs({ originTokenAmount: new TokenAmount(amount, token), destination, - sender: senderAddress, - recipient: recipient ?? senderAddress, + sender: signerAddress, + recipient, }); const txReceipts = []; @@ -172,7 +176,7 @@ async function executeDelivery({ const parsed = parseWarpRouteMessage(message.parsed.body); logBlue( - `Sent transfer from sender (${senderAddress}) on ${origin} to recipient (${recipient}) on ${destination}.`, + `Sent transfer from sender (${signerAddress}) on ${origin} to recipient (${recipient}) on ${destination}.`, ); logBlue(`Message ID: ${message.id}`); log(`Message:\n${indentYamlOrJson(yamlStringify(message, null, 2), 4)}`); diff --git a/typescript/cli/src/tests/commands/helpers.ts b/typescript/cli/src/tests/commands/helpers.ts index c6bfdc9a7c..7853815459 100644 --- a/typescript/cli/src/tests/commands/helpers.ts +++ b/typescript/cli/src/tests/commands/helpers.ts @@ -1,3 +1,4 @@ +import { ethers } from 'ethers'; import { $ } from 'zx'; import { ERC20Test__factory, ERC4626Test__factory } from '@hyperlane-xyz/core'; @@ -160,6 +161,9 @@ export async function deployToken(privateKey: string, chain: string) { key: privateKey, }); + // Future works: make signer compatible with protocol/chain stack + multiProvider.setSigner(chain, new ethers.Wallet(privateKey)); + const token = await new ERC20Test__factory( multiProvider.getSigner(chain), ).deploy('token', 'token', '100000000000000000000', 18); @@ -179,6 +183,9 @@ export async function deploy4626Vault( key: privateKey, }); + // Future works: make signer compatible with protocol/chain stack + multiProvider.setSigner(chain, new ethers.Wallet(privateKey)); + const vault = await new ERC4626Test__factory( multiProvider.getSigner(chain), ).deploy(tokenAddress, 'VAULT', 'VAULT'); diff --git a/typescript/cli/src/utils/balances.ts b/typescript/cli/src/utils/balances.ts index 4536353e57..2a6e6fcb8a 100644 --- a/typescript/cli/src/utils/balances.ts +++ b/typescript/cli/src/utils/balances.ts @@ -8,12 +8,9 @@ import { logGray, logGreen, logRed } from '../logger.js'; export async function nativeBalancesAreSufficient( multiProvider: MultiProvider, - signer: ethers.Signer, chains: ChainName[], minGas: string, ) { - const address = await signer.getAddress(); - const sufficientBalances: boolean[] = []; for (const chain of chains) { // Only Ethereum chains are supported @@ -21,7 +18,7 @@ export async function nativeBalancesAreSufficient( logGray(`Skipping balance check for non-EVM chain: ${chain}`); continue; } - + const address = multiProvider.getSigner(chain).getAddress(); const provider = multiProvider.getProvider(chain); const gasPrice = await provider.getGasPrice(); const minBalanceWei = gasPrice.mul(minGas).toString(); diff --git a/typescript/cli/src/utils/chains.ts b/typescript/cli/src/utils/chains.ts index add11203d0..7e2eaccd0a 100644 --- a/typescript/cli/src/utils/chains.ts +++ b/typescript/cli/src/utils/chains.ts @@ -171,3 +171,36 @@ function handleNewChain(chainNames: string[]) { process.exit(0); } } + +/** + * @notice Extracts chain names from a nested configuration object + * @param config Object to search for chain names + * @return Array of discovered chain names + */ +export function extractChainsFromObj(config: Record): string[] { + const chains: string[] = []; + + // Recursively search for chain/chainName fields + function findChainFields(obj: any) { + if (obj === null || typeof obj !== 'object') return; + + if (Array.isArray(obj)) { + obj.forEach((item) => findChainFields(item)); + return; + } + + if ('chain' in obj) { + chains.push(obj.chain); + } + + if ('chainName' in obj) { + chains.push(obj.chainName); + } + + // Recursively search in all nested values + Object.values(obj).forEach((value) => findChainFields(value)); + } + + findChainFields(config); + return chains; +} diff --git a/typescript/cli/src/utils/output.ts b/typescript/cli/src/utils/output.ts index 442b8a0906..2e1acfdf41 100644 --- a/typescript/cli/src/utils/output.ts +++ b/typescript/cli/src/utils/output.ts @@ -54,3 +54,50 @@ export function formatYamlViolationsOutput( return highlightedLines.join('\n'); } + +/** + * @notice Masks sensitive key with dots + * @param key Sensitive key to mask + * @return Masked key + */ +export function maskSensitiveKey(key: string): string { + if (!key) return key; + const middle = '•'.repeat(key.length); + return `${middle}`; +} + +const SENSITIVE_PATTERNS = [ + 'privatekey', + 'key', + 'secret', + 'secretkey', + 'password', +]; + +const isSensitiveKey = (key: string) => { + const lowerKey = key.toLowerCase(); + return SENSITIVE_PATTERNS.some((pattern) => lowerKey.includes(pattern)); +}; + +/** + * @notice Recursively masks sensitive data in objects + * @param obj Object with potential sensitive data + * @return Object with masked sensitive data + */ +export function maskSensitiveData(obj: any): any { + if (!obj) return obj; + + if (typeof obj === 'object') { + const masked = { ...obj }; + for (const [key, value] of Object.entries(masked)) { + if (isSensitiveKey(key) && typeof value === 'string') { + masked[key] = maskSensitiveKey(value); + } else if (typeof value === 'object') { + masked[key] = maskSensitiveData(value); + } + } + return masked; + } + + return obj; +} diff --git a/typescript/sdk/src/providers/transactions/submitter/ethersV5/types.ts b/typescript/sdk/src/providers/transactions/submitter/ethersV5/types.ts index d1e1c7a90c..bf0d29d540 100644 --- a/typescript/sdk/src/providers/transactions/submitter/ethersV5/types.ts +++ b/typescript/sdk/src/providers/transactions/submitter/ethersV5/types.ts @@ -23,6 +23,8 @@ export type EV5GnosisSafeTxBuilderProps = z.infer< export const EV5JsonRpcTxSubmitterPropsSchema = z.object({ chain: ZChainName, + userAddress: ZHash.optional(), + privateKey: ZHash.optional(), }); export type EV5JsonRpcTxSubmitterProps = z.infer< diff --git a/typescript/utils/src/addresses.ts b/typescript/utils/src/addresses.ts index 29a35b6b88..a244c810ba 100644 --- a/typescript/utils/src/addresses.ts +++ b/typescript/utils/src/addresses.ts @@ -1,6 +1,6 @@ import { fromBech32, normalizeBech32, toBech32 } from '@cosmjs/encoding'; import { PublicKey } from '@solana/web3.js'; -import { utils as ethersUtils } from 'ethers'; +import { Wallet, utils as ethersUtils } from 'ethers'; import { isNullish } from './typeof.js'; import { Address, HexString, ProtocolType } from './types.js'; @@ -380,3 +380,11 @@ export function ensure0x(hexstr: string) { export function strip0x(hexstr: string) { return hexstr.startsWith('0x') ? hexstr.slice(2) : hexstr; } + +export function isPrivateKeyEvm(privateKey: string): boolean { + try { + return new Wallet(privateKey).privateKey === privateKey; + } catch { + throw new Error('Provided Private Key is not EVM compatible!'); + } +} diff --git a/typescript/utils/src/index.ts b/typescript/utils/src/index.ts index 8314418631..f4bd9779cb 100644 --- a/typescript/utils/src/index.ts +++ b/typescript/utils/src/index.ts @@ -26,6 +26,7 @@ export { isValidAddressCosmos, isValidAddressEvm, isValidAddressSealevel, + isPrivateKeyEvm, isValidTransactionHash, isValidTransactionHashCosmos, isValidTransactionHashEvm,