diff --git a/.changeset/flat-swans-help.md b/.changeset/flat-swans-help.md new file mode 100644 index 0000000000..e5f0e21118 --- /dev/null +++ b/.changeset/flat-swans-help.md @@ -0,0 +1,7 @@ +--- +'@hyperlane-xyz/utils': minor +'@hyperlane-xyz/cli': minor +'@hyperlane-xyz/sdk': minor +--- + +Adds new `core check` command to compare local configuration and on chain deployments. Adds memoization to the EvmHookReader to avoid repeating configuration derivation diff --git a/typescript/cli/src/commands/core.ts b/typescript/cli/src/commands/core.ts index f691de750a..60d84f8776 100644 --- a/typescript/cli/src/commands/core.ts +++ b/typescript/cli/src/commands/core.ts @@ -1,10 +1,13 @@ +import { stringify as yamlStringify } from 'yaml'; import { CommandModule } from 'yargs'; import { + CoreConfig, DeployedCoreAddresses, DeployedCoreAddressesSchema, - EvmCoreReader, + normalizeConfig, } from '@hyperlane-xyz/sdk'; +import { diffObjMerge } from '@hyperlane-xyz/utils'; import { createCoreDeployConfig, @@ -16,17 +19,21 @@ import { } from '../context/types.js'; import { runCoreApply, runCoreDeploy } from '../deploy/core.js'; import { evaluateIfDryRunFailure } from '../deploy/dry-run.js'; -import { errorRed, log, logGray, logGreen } from '../logger.js'; +import { log, logCommandHeader, logGreen } from '../logger.js'; +import { executeCoreRead } from '../read/core.js'; import { logYamlIfUnderMaxLines, readYamlOrJson, writeYamlOrJson, } from '../utils/files.js'; +import { formatYamlViolationsOutput } from '../utils/output.js'; import { + DEFAULT_CORE_DEPLOYMENT_CONFIG_PATH, chainCommandOption, dryRunCommandOption, fromAddressCommandOption, + inputFileCommandOption, outputFileCommandOption, skipConfirmationOption, } from './options.js'; @@ -40,6 +47,7 @@ export const coreCommand: CommandModule = { builder: (yargs) => yargs .command(apply) + .command(check) .command(deploy) .command(init) .command(read) @@ -47,6 +55,7 @@ export const coreCommand: CommandModule = { .demandCommand(), handler: () => log('Command required'), }; + export const apply: CommandModuleWithWriteContext<{ chain: string; config: string; @@ -60,14 +69,13 @@ export const apply: CommandModuleWithWriteContext<{ demandOption: true, }, config: outputFileCommandOption( - './configs/core-config.yaml', + DEFAULT_CORE_DEPLOYMENT_CONFIG_PATH, true, 'The path to output a Core Config JSON or YAML file.', ), }, handler: async ({ context, chain, config: configFilePath }) => { - logGray(`Hyperlane Core Apply`); - logGray('--------------------'); + logCommandHeader(`Hyperlane Core Apply`); const addresses = (await context.registry.getChainAddresses( chain, @@ -103,7 +111,7 @@ export const deploy: CommandModuleWithWriteContext<{ builder: { chain: chainCommandOption, config: outputFileCommandOption( - './configs/core-config.yaml', + DEFAULT_CORE_DEPLOYMENT_CONFIG_PATH, false, 'The path to a JSON or YAML file with a core deployment config.', ), @@ -112,8 +120,7 @@ export const deploy: CommandModuleWithWriteContext<{ 'skip-confirmation': skipConfirmationOption, }, handler: async ({ context, chain, config: configFilePath, dryRun }) => { - logGray(`Hyperlane Core deployment${dryRun ? ' dry-run' : ''}`); - logGray(`------------------------------------------------`); + logCommandHeader(`Hyperlane Core deployment${dryRun ? ' dry-run' : ''}`); try { await runCoreDeploy({ @@ -142,14 +149,13 @@ export const init: CommandModuleWithContext<{ default: false, }, config: outputFileCommandOption( - './configs/core-config.yaml', + DEFAULT_CORE_DEPLOYMENT_CONFIG_PATH, false, 'The path to output a Core Config JSON or YAML file.', ), }, handler: async ({ context, advanced, config: configFilePath }) => { - logGray('Hyperlane Core Configure'); - logGray('------------------------'); + logCommandHeader('Hyperlane Core Configure'); await createCoreDeployConfig({ context, @@ -178,39 +184,70 @@ export const read: CommandModuleWithContext<{ description: 'Mailbox address used to derive the core config', }, config: outputFileCommandOption( - './configs/core-config.yaml', + DEFAULT_CORE_DEPLOYMENT_CONFIG_PATH, false, 'The path to output a Core Config JSON or YAML file.', ), }, handler: async ({ context, chain, mailbox, config: configFilePath }) => { - if (!mailbox) { - const addresses = await context.registry.getChainAddresses(chain); - mailbox = addresses?.mailbox; - if (!mailbox) { - throw new Error( - `${chain} mailbox not provided and none found in registry.`, - ); - } - } + logCommandHeader('Hyperlane Core Read'); - logGray('Hyperlane Core Read'); - logGray('-------------------'); + const coreConfig = await executeCoreRead({ context, chain, mailbox }); - const evmCoreReader = new EvmCoreReader(context.multiProvider, chain); - try { - const coreConfig = await evmCoreReader.deriveCoreConfig(mailbox); - writeYamlOrJson(configFilePath, coreConfig, 'yaml'); - logGreen(`✅ Core config written successfully to ${configFilePath}:\n`); - logYamlIfUnderMaxLines(coreConfig); - } catch (e: any) { - errorRed( - `❌ Failed to read core config for mailbox ${mailbox} on ${chain}:`, - e, - ); + writeYamlOrJson(configFilePath, coreConfig, 'yaml'); + logGreen(`✅ Core config written successfully to ${configFilePath}:\n`); + logYamlIfUnderMaxLines(coreConfig); + + process.exit(0); + }, +}; + +export const check: CommandModuleWithContext<{ + chain: string; + config: string; + mailbox?: string; +}> = { + command: 'check', + describe: + 'Reads onchain Core configuration for a given mailbox address and compares it with a provided file', + builder: { + chain: { + ...chainCommandOption, + demandOption: true, + }, + mailbox: { + type: 'string', + description: + 'Mailbox address used to derive the core config. If not provided it will be inferred from the registry', + }, + config: inputFileCommandOption({ + defaultPath: DEFAULT_CORE_DEPLOYMENT_CONFIG_PATH, + description: 'The path to a a Core Config JSON or YAML file.', + demandOption: false, + }), + }, + handler: async ({ context, chain, mailbox, config: configFilePath }) => { + logCommandHeader('Hyperlane Core Check'); + + const expectedCoreConfig: CoreConfig = await readYamlOrJson(configFilePath); + const onChainCoreConfig = await executeCoreRead({ + context, + chain, + mailbox, + }); + + const { mergedObject, isInvalid } = diffObjMerge( + normalizeConfig(onChainCoreConfig), + normalizeConfig(expectedCoreConfig), + ); + + if (isInvalid) { + log(formatYamlViolationsOutput(yamlStringify(mergedObject, null, 2))); process.exit(1); } + logGreen(`No violations found`); + process.exit(0); }, }; diff --git a/typescript/cli/src/commands/options.ts b/typescript/cli/src/commands/options.ts index 218671509f..f23194c804 100644 --- a/typescript/cli/src/commands/options.ts +++ b/typescript/cli/src/commands/options.ts @@ -94,6 +94,8 @@ export const hookCommandOption: Options = { 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 warpDeploymentConfigCommandOption: Options = { type: 'string', description: diff --git a/typescript/cli/src/read/core.ts b/typescript/cli/src/read/core.ts new file mode 100644 index 0000000000..d69465274f --- /dev/null +++ b/typescript/cli/src/read/core.ts @@ -0,0 +1,35 @@ +import { ChainName, CoreConfig, EvmCoreReader } from '@hyperlane-xyz/sdk'; +import { Address } from '@hyperlane-xyz/utils'; + +import { CommandContext } from '../context/types.js'; +import { errorRed } from '../logger.js'; + +export async function executeCoreRead({ + context, + chain, + mailbox, +}: { + context: CommandContext; + chain: ChainName; + mailbox?: Address; +}): Promise { + if (!mailbox) { + const addresses = await context.registry.getChainAddresses(chain); + mailbox = addresses?.mailbox; + if (!mailbox) { + errorRed(`${chain} mailbox not provided and none found in registry.`); + process.exit(1); + } + } + + const evmCoreReader = new EvmCoreReader(context.multiProvider, chain); + try { + return evmCoreReader.deriveCoreConfig(mailbox); + } catch (e: any) { + errorRed( + `❌ Failed to read core config for mailbox ${mailbox} on ${chain}:`, + e, + ); + process.exit(1); + } +} diff --git a/typescript/sdk/src/hook/EvmHookReader.ts b/typescript/sdk/src/hook/EvmHookReader.ts index f54c4cf500..b1b7e56bde 100644 --- a/typescript/sdk/src/hook/EvmHookReader.ts +++ b/typescript/sdk/src/hook/EvmHookReader.ts @@ -83,6 +83,12 @@ export interface HookReader { export class EvmHookReader extends HyperlaneReader implements HookReader { protected readonly logger = rootLogger.child({ module: 'EvmHookReader' }); + /** + * HookConfig cache for already retrieved configs. Useful to avoid recomputing configs + * when they have already been retrieved in previous calls where `deriveHookConfig` was called by + * the specific hook methods. + */ + private _cache: Map = new Map(); constructor( protected readonly multiProvider: MultiProvider, @@ -95,11 +101,24 @@ export class EvmHookReader extends HyperlaneReader implements HookReader { } async deriveHookConfig(address: Address): Promise { + this.logger.debug('Deriving HookConfig:', { address }); + + const cachedValue = this._cache.get(address); + if (cachedValue) { + this.logger.debug( + `Cache hit for HookConfig on chain ${this.chain} at: ${address}`, + ); + return cachedValue; + } + + this.logger.debug( + `Cache miss for HookConfig on chain ${this.chain} at: ${address}`, + ); + let onchainHookType: OnchainHookType | undefined = undefined; let derivedHookConfig: DerivedHookConfig; try { const hook = IPostDispatchHook__factory.connect(address, this.provider); - this.logger.debug('Deriving HookConfig:', { address }); // Temporarily turn off SmartProvider logging // Provider errors are expected because deriving will call methods that may not exist in the Bytecode @@ -168,10 +187,14 @@ export class EvmHookReader extends HyperlaneReader implements HookReader { const hook = MerkleTreeHook__factory.connect(address, this.provider); this.assertHookType(await hook.hookType(), OnchainHookType.MERKLE_TREE); - return { + const config: WithAddress = { address, type: HookType.MERKLE_TREE, }; + + this._cache.set(address, config); + + return config; } async deriveAggregationConfig( @@ -187,11 +210,15 @@ export class EvmHookReader extends HyperlaneReader implements HookReader { (hook) => this.deriveHookConfig(hook), ); - return { + const config: WithAddress = { address, type: HookType.AGGREGATION, hooks: hookConfigs, }; + + this._cache.set(address, config); + + return config; } async deriveIgpConfig(address: Address): Promise> { @@ -259,7 +286,7 @@ export class EvmHookReader extends HyperlaneReader implements HookReader { oracleKey = resolvedOracleKeys[0]; } - return { + const config: WithAddress = { owner, address, type: HookType.INTERCHAIN_GAS_PAYMASTER, @@ -268,6 +295,10 @@ export class EvmHookReader extends HyperlaneReader implements HookReader { overhead, oracleConfig, }; + + this._cache.set(address, config); + + return config; } async deriveProtocolFeeConfig( @@ -281,7 +312,7 @@ export class EvmHookReader extends HyperlaneReader implements HookReader { const protocolFee = await hook.protocolFee(); const beneficiary = await hook.beneficiary(); - return { + const config: WithAddress = { owner, address, type: HookType.PROTOCOL_FEE, @@ -289,6 +320,10 @@ export class EvmHookReader extends HyperlaneReader implements HookReader { protocolFee: protocolFee.toString(), beneficiary, }; + + this._cache.set(address, config); + + return config; } async deriveOpStackConfig( @@ -303,13 +338,17 @@ export class EvmHookReader extends HyperlaneReader implements HookReader { const destinationChainName = this.multiProvider.getChainName(destinationDomain); - return { + const config: WithAddress = { owner, address, type: HookType.OP_STACK, nativeBridge: messengerContract, destinationChain: destinationChainName, }; + + this._cache.set(address, config); + + return config; } async deriveArbL2ToL1Config( @@ -321,12 +360,17 @@ export class EvmHookReader extends HyperlaneReader implements HookReader { const destinationDomain = await hook.destinationDomain(); const destinationChainName = this.multiProvider.getChainName(destinationDomain); - return { + + const config: WithAddress = { address, type: HookType.ARB_L2_TO_L1, destinationChain: destinationChainName, arbSys, }; + + this._cache.set(address, config); + + return config; } async deriveDomainRoutingConfig( @@ -338,12 +382,16 @@ export class EvmHookReader extends HyperlaneReader implements HookReader { const owner = await hook.owner(); const domainHooks = await this.fetchDomainHooks(hook); - return { + const config: WithAddress = { owner, address, type: HookType.ROUTING, domains: domainHooks, }; + + this._cache.set(address, config); + + return config; } async deriveFallbackRoutingConfig( @@ -364,13 +412,17 @@ export class EvmHookReader extends HyperlaneReader implements HookReader { const fallbackHook = await hook.fallbackHook(); const fallbackHookConfig = await this.deriveHookConfig(fallbackHook); - return { + const config: WithAddress = { owner, address, type: HookType.FALLBACK_ROUTING, domains: domainHooks, fallback: fallbackHookConfig, }; + + this._cache.set(address, config); + + return config; } private async fetchDomainHooks( @@ -406,12 +458,16 @@ export class EvmHookReader extends HyperlaneReader implements HookReader { const owner = await hook.owner(); const paused = await hook.paused(); - return { + const config: WithAddress = { owner, address, paused, type: HookType.PAUSABLE, }; + + this._cache.set(address, config); + + return config; } assertHookType(