feat: cli core checker (#4687)

### Description

This PR implements a new `core check` command to allow comparing local
core contract configuration with the on chain deployment and detect
mismatches.

Examples:


![image](https://github.com/user-attachments/assets/5f0cd24a-45b1-4999-92d2-aa75182f9ef7)


![image](https://github.com/user-attachments/assets/e6b00714-0234-4f54-b63d-69aebd8148e9)


### Drive-by changes

- Defined the `DEFAULT_CORE_DEPLOYMENT_CONFIG_PATH` to remove repeated
and hardcoded './configs/core-config.yaml' strings
- Implemented the `executeCoreRead` function to reuse it in the `core
check` command logic.
- Added memorization to the `EvmHookReader` because reading on chain
configuration for chains like `arbitrum`, `ethereum` or `optimism` was
taking more than 10 minutes to complete due to repeated hook config
retrieval

### Related issues

- https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/4666

### Backward compatibility

- Yes

### Testing

- Manual

### Notes

- Please merge #4667 before this PR because this was built on top of it
pull/4659/merge
xeno097 1 month ago committed by GitHub
parent e15dc267b8
commit 29341950e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      .changeset/flat-swans-help.md
  2. 105
      typescript/cli/src/commands/core.ts
  3. 2
      typescript/cli/src/commands/options.ts
  4. 36
      typescript/cli/src/read/core.ts
  5. 76
      typescript/sdk/src/hook/EvmHookReader.ts

@ -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

@ -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);
},
};

@ -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:

@ -0,0 +1,36 @@
import { ChainName, CoreConfig, EvmCoreReader } from '@hyperlane-xyz/sdk';
import { Address, assert } 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<CoreConfig> {
if (!mailbox) {
const addresses = await context.registry.getChainAddresses(chain);
mailbox = addresses?.mailbox;
assert(
mailbox,
`${chain} mailbox not provided and none found in registry.`,
);
}
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);
}
}

@ -84,6 +84,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<Address, any> = new Map();
constructor(
protected readonly multiProvider: MultiProvider,
@ -96,12 +102,25 @@ export class EvmHookReader extends HyperlaneReader implements HookReader {
}
async deriveHookConfig(address: Address): Promise<DerivedHookConfig> {
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}`,
);
return retryAsync(async () => {
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
@ -171,10 +190,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<MerkleTreeHookConfig> = {
address,
type: HookType.MERKLE_TREE,
};
this._cache.set(address, config);
return config;
}
async deriveAggregationConfig(
@ -190,11 +213,15 @@ export class EvmHookReader extends HyperlaneReader implements HookReader {
(hook) => this.deriveHookConfig(hook),
);
return {
const config: WithAddress<AggregationHookConfig> = {
address,
type: HookType.AGGREGATION,
hooks: hookConfigs,
};
this._cache.set(address, config);
return config;
}
async deriveIgpConfig(address: Address): Promise<WithAddress<IgpHookConfig>> {
@ -262,7 +289,7 @@ export class EvmHookReader extends HyperlaneReader implements HookReader {
oracleKey = resolvedOracleKeys[0];
}
return {
const config: WithAddress<IgpHookConfig> = {
owner,
address,
type: HookType.INTERCHAIN_GAS_PAYMASTER,
@ -271,6 +298,10 @@ export class EvmHookReader extends HyperlaneReader implements HookReader {
overhead,
oracleConfig,
};
this._cache.set(address, config);
return config;
}
async deriveProtocolFeeConfig(
@ -284,7 +315,7 @@ export class EvmHookReader extends HyperlaneReader implements HookReader {
const protocolFee = await hook.protocolFee();
const beneficiary = await hook.beneficiary();
return {
const config: WithAddress<ProtocolFeeHookConfig> = {
owner,
address,
type: HookType.PROTOCOL_FEE,
@ -292,6 +323,10 @@ export class EvmHookReader extends HyperlaneReader implements HookReader {
protocolFee: protocolFee.toString(),
beneficiary,
};
this._cache.set(address, config);
return config;
}
async deriveOpStackConfig(
@ -306,13 +341,17 @@ export class EvmHookReader extends HyperlaneReader implements HookReader {
const destinationChainName =
this.multiProvider.getChainName(destinationDomain);
return {
const config: WithAddress<OpStackHookConfig> = {
owner,
address,
type: HookType.OP_STACK,
nativeBridge: messengerContract,
destinationChain: destinationChainName,
};
this._cache.set(address, config);
return config;
}
async deriveArbL2ToL1Config(
@ -324,12 +363,17 @@ export class EvmHookReader extends HyperlaneReader implements HookReader {
const destinationDomain = await hook.destinationDomain();
const destinationChainName =
this.multiProvider.getChainName(destinationDomain);
return {
const config: WithAddress<ArbL2ToL1HookConfig> = {
address,
type: HookType.ARB_L2_TO_L1,
destinationChain: destinationChainName,
arbSys,
};
this._cache.set(address, config);
return config;
}
async deriveDomainRoutingConfig(
@ -341,12 +385,16 @@ export class EvmHookReader extends HyperlaneReader implements HookReader {
const owner = await hook.owner();
const domainHooks = await this.fetchDomainHooks(hook);
return {
const config: WithAddress<DomainRoutingHookConfig> = {
owner,
address,
type: HookType.ROUTING,
domains: domainHooks,
};
this._cache.set(address, config);
return config;
}
async deriveFallbackRoutingConfig(
@ -367,13 +415,17 @@ export class EvmHookReader extends HyperlaneReader implements HookReader {
const fallbackHook = await hook.fallbackHook();
const fallbackHookConfig = await this.deriveHookConfig(fallbackHook);
return {
const config: WithAddress<FallbackRoutingHookConfig> = {
owner,
address,
type: HookType.FALLBACK_ROUTING,
domains: domainHooks,
fallback: fallbackHookConfig,
};
this._cache.set(address, config);
return config;
}
private async fetchDomainHooks(
@ -409,12 +461,16 @@ export class EvmHookReader extends HyperlaneReader implements HookReader {
const owner = await hook.owner();
const paused = await hook.paused();
return {
const config: WithAddress<PausableHookConfig> = {
owner,
address,
paused,
type: HookType.PAUSABLE,
};
this._cache.set(address, config);
return config;
}
assertHookType(

Loading…
Cancel
Save