feat(infra): ensure check-deploy covers ownable ISMs (#4236)

### Description
- check-deploy will surface violations when the on-chain ISM state
differs from the config for core and warp modules

Example of running check-deploy when the warp config includes a new
validator Vs on-chain ISM state

```
┌─────────┬────────────┬────────┬──────────────┬─────────────┬─────────┬──────────────────────────────────────────────┬──────────────────────────────────────────────┐
│ (index) │ chain      │ remote │ name         │ type        │ subType │ actual                                       │ expected                                     │
├─────────┼────────────┼────────┼──────────────┼─────────────┼─────────┼──────────────────────────────────────────────┼──────────────────────────────────────────────┤
│ 0       │ 'ethereum' │        │              │ 'ClientIsm' │         │ [Object]                                     │ [Object]                                     │
│ 1       │ 'ethereum' │        │ 'collateral' │ 'Owner'     │         │ '0x3965AC3D295641E452E0ea896a086A9cD7C6C5b6' │ '0xa7ECcdb9Be08178f896c26b7BbD8C3D4E844d9Ba' │
└─────────┴────────────┴────────┴──────────────┴─────────────┴─────────┴──────────────────────────────────────────────┴──────────────────────────────────────────────┘
Connection client violation ClientIsm details:
+ Added to config staticAggregationIsm.modules.0.merkleRootMultisigIsm.validators.3: "0xbb5842ae0e05215b53df4787a29144efb7e67551"
+ Added to config staticAggregationIsm.modules.1.messageIdMultisigIsm.validators.3: "0xbb5842ae0e05215b53df4787a29144efb7e67551"
~ Updated config staticAggregationIsm.modules.0.merkleRootMultisigIsm.validators.0: "0x95c7bf235837cb5a609fe6c95870410b9f68bcff" -> "0x4d966438fe9e2b1e7124c87bbb90cb4f0f6c59a1"
~ Updated config staticAggregationIsm.modules.0.merkleRootMultisigIsm.validators.1: "0xa5a56e97fb46f0ac3a3d261e404acb998d9a6969" -> "0x95c7bf235837cb5a609fe6c95870410b9f68bcff"
~ Updated config staticAggregationIsm.modules.0.merkleRootMultisigIsm.validators.2: "0xbb5842ae0e05215b53df4787a29144efb7e67551" -> "0xa5a56e97fb46f0ac3a3d261e404acb998d9a6969"
~ Updated config staticAggregationIsm.modules.1.messageIdMultisigIsm.validators.0: "0x95c7bf235837cb5a609fe6c95870410b9f68bcff" -> "0x4d966438fe9e2b1e7124c87bbb90cb4f0f6c59a1"
~ Updated config staticAggregationIsm.modules.1.messageIdMultisigIsm.validators.1: "0xa5a56e97fb46f0ac3a3d261e404acb998d9a6969" -> "0x95c7bf235837cb5a609fe6c95870410b9f68bcff"
~ Updated config staticAggregationIsm.modules.1.messageIdMultisigIsm.validators.2: "0xbb5842ae0e05215b53df4787a29144efb7e67551" -> "0xa5a56e97fb46f0ac3a3d261e404acb998d9a6969"
```

### Backward compatibility

Yes

### Testing

Manual
pull/4246/head
Mohammed Hussan 4 months ago committed by GitHub
parent fe9365fdb3
commit c21a99abf8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      typescript/infra/package.json
  2. 4
      typescript/infra/scripts/check-deploy.ts
  3. 258
      typescript/infra/src/utils/violation.ts
  4. 11
      typescript/sdk/src/core/HyperlaneCoreChecker.ts
  5. 10
      typescript/sdk/src/router/HyperlaneRouterChecker.ts
  6. 8
      yarn.lock

@ -22,6 +22,7 @@
"@solana/web3.js": "^1.78.0",
"asn1.js": "5.4.1",
"aws-kms-ethers-signer": "^0.1.3",
"deep-object-diff": "^1.1.9",
"dotenv": "^10.0.0",
"json-stable-stringify": "^1.1.1",
"prom-client": "^14.0.1",

@ -21,6 +21,7 @@ import { HyperlaneIgpGovernor } from '../src/govern/HyperlaneIgpGovernor.js';
import { ProxiedRouterGovernor } from '../src/govern/ProxiedRouterGovernor.js';
import { Role } from '../src/roles.js';
import { impersonateAccount, useLocalProvider } from '../src/utils/fork.js';
import { logViolationDetails } from '../src/utils/violation.js';
import {
Modules,
@ -173,6 +174,9 @@ async function check() {
'actual',
'expected',
]);
logViolationDetails(violations);
if (!fork) {
throw new Error(
`Checking ${module} deploy yielded ${violations.length} violations`,

@ -0,0 +1,258 @@
import chalk from 'chalk';
import { addedDiff, deletedDiff, updatedDiff } from 'deep-object-diff';
import stringify from 'json-stable-stringify';
import { fromError } from 'zod-validation-error';
import {
CheckerViolation,
ConnectionClientViolationType,
CoreViolationType,
IsmConfigSchema,
MailboxViolation,
MailboxViolationType,
} from '@hyperlane-xyz/sdk';
interface AnyObject {
[key: string]: any;
}
const enum ChangeType {
Added = 'Added',
Deleted = 'Deleted',
Updated = 'Updated',
}
const changeTypeMapping: Record<ChangeType, string> = {
[ChangeType.Added]: '+ Added to config',
[ChangeType.Deleted]: '- Deleted from config',
[ChangeType.Updated]: '~ Updated config',
};
const ignoreFields = ['address', 'ownerOverrides'];
function updatePath(json: AnyObject, path: string): string {
const pathParts = path.split('.');
const newPathParts: string[] = [];
let currentObject: AnyObject | undefined = json;
for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i];
if (currentObject && typeof currentObject === 'object') {
if ('type' in currentObject) {
newPathParts.push(currentObject.type);
}
if (part in currentObject) {
currentObject = currentObject[part];
} else {
currentObject = undefined;
}
}
newPathParts.push(part);
}
return newPathParts.join('.');
}
function getElement(config: AnyObject | undefined, path: string) {
const parts = path.split('.');
let currentObject: AnyObject | undefined = config;
for (const part of parts) {
if (currentObject && typeof currentObject === 'object') {
if (part in currentObject) {
currentObject = currentObject[part];
} else {
return undefined;
}
} else {
return undefined;
}
}
return currentObject;
}
function toLowerCaseValues(obj: any): any {
if (typeof obj === 'string') {
return obj.toLowerCase();
}
if (typeof obj !== 'object' || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(toLowerCaseValues);
}
return Object.keys(obj).reduce((acc: any, key: string) => {
if (key !== 'type') {
acc[key] = toLowerCaseValues(obj[key]);
} else {
acc[key] = obj[key];
}
return acc;
}, {});
}
function sortArraysByType(obj: AnyObject): AnyObject {
if (Array.isArray(obj)) {
return obj
.sort((a, b) => {
if (a.type < b.type) return -1;
if (a.type > b.type) return 1;
return 0;
})
.map((item) => sortArraysByType(item));
} else if (typeof obj === 'object' && obj !== null) {
const sortedObj: AnyObject = {};
Object.keys(obj).forEach((key) => {
sortedObj[key] = sortArraysByType(obj[key]);
});
return sortedObj;
}
return obj;
}
function removeFields(obj: any): any {
if (Array.isArray(obj)) {
return obj.map(removeFields);
} else if (obj !== null && typeof obj === 'object') {
return Object.keys(obj).reduce((result: any, key: string) => {
if (!ignoreFields.includes(key)) {
result[key] = removeFields(obj[key]);
}
return result;
}, {});
} else {
return obj;
}
}
function logDiff(expected: AnyObject, actual: AnyObject): void {
const sortedExpected = sortArraysByType(expected);
const sortedActual = sortArraysByType(actual);
const sortedExpectedJson = stringify(sortedExpected, { space: 2 });
const sortedActualJson = stringify(sortedActual, { space: 2 });
const parsedSortedExpected = JSON.parse(sortedExpectedJson);
const parsedSortedActual = JSON.parse(sortedActualJson);
const added = addedDiff(parsedSortedActual, parsedSortedExpected);
const deleted = deletedDiff(parsedSortedActual, parsedSortedExpected);
const updated = updatedDiff(parsedSortedActual, parsedSortedExpected);
const logChanges = (
changes: AnyObject,
changeType: ChangeType,
color: (text: string) => string,
config: AnyObject,
otherConfig?: AnyObject,
) => {
const logChange = (obj: AnyObject, path: string = '') => {
Object.keys(obj).forEach((key) => {
const currentPath = path ? `${path}.${key}` : key;
if (
typeof obj[key] === 'object' &&
obj[key] !== null &&
!Array.isArray(obj[key])
) {
logChange(obj[key], currentPath);
} else {
if (changeType !== ChangeType.Updated) {
console.log(
color(
`${changeTypeMapping[changeType]} ${updatePath(
config,
currentPath,
)}: ${stringify(obj[key], {
space: 2,
})}`,
),
);
} else {
console.log(
color(
`${changeTypeMapping[changeType]} ${updatePath(
config,
currentPath,
)}: ${stringify(getElement(otherConfig, currentPath), {
space: 2,
})} -> ${stringify(obj[key], { space: 2 })}`,
),
);
}
}
});
};
logChange(changes);
};
logChanges(added, ChangeType.Added, chalk.green, parsedSortedActual);
logChanges(deleted, ChangeType.Deleted, chalk.red, parsedSortedActual);
logChanges(
updated,
ChangeType.Updated,
chalk.yellow,
parsedSortedExpected,
parsedSortedActual,
);
}
function preProcessConfig(config: any) {
return removeFields(toLowerCaseValues(config));
}
function logViolationDetail(violation: CheckerViolation): void {
const preProcessedExpectedConfig = preProcessConfig(violation.expected);
const preProcessedActualConfig = preProcessConfig(violation.actual);
const expectedConfigResult = IsmConfigSchema.safeParse(
preProcessedExpectedConfig,
);
const actualConfigResult = IsmConfigSchema.safeParse(
preProcessedActualConfig,
);
if (!expectedConfigResult.success || !actualConfigResult.success) {
if (!expectedConfigResult.success) {
console.error(
'Failed to parse expected config',
fromError(expectedConfigResult.error).toString(),
);
}
if (!actualConfigResult.success) {
console.error(
'Failed to parse actual config',
fromError(actualConfigResult.error).toString(),
);
}
return;
}
logDiff(preProcessedExpectedConfig, preProcessedActualConfig);
}
export function logViolationDetails(violations: CheckerViolation[]): void {
for (const violation of violations) {
if (violation.type === CoreViolationType.Mailbox) {
const mailboxViolation = violation as MailboxViolation;
if (mailboxViolation.subType === MailboxViolationType.DefaultIsm) {
console.log(`Mailbox violation ${mailboxViolation.subType} details:`);
logViolationDetail(violation);
}
}
if (
violation.type === ConnectionClientViolationType.InterchainSecurityModule
) {
console.log(`Connection client violation ${violation.type} details:`);
logViolationDetail(violation);
}
}
}

@ -5,6 +5,7 @@ import { assert, eqAddress } from '@hyperlane-xyz/utils';
import { BytecodeHash } from '../consts/bytecode.js';
import { HyperlaneAppChecker } from '../deploy/HyperlaneAppChecker.js';
import { proxyImplementation } from '../deploy/proxy.js';
import { EvmIsmReader } from '../ism/EvmIsmReader.js';
import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js';
import { collectValidators, moduleMatchesConfig } from '../ism/utils.js';
import { MultiProvider } from '../providers/MultiProvider.js';
@ -65,23 +66,27 @@ export class HyperlaneCoreChecker extends HyperlaneAppChecker<
)} for ${chain}`,
);
const actualIsm = await mailbox.defaultIsm();
const actualIsmAddress = await mailbox.defaultIsm();
const config = this.configMap[chain];
const matches = await moduleMatchesConfig(
chain,
actualIsm,
actualIsmAddress,
config.defaultIsm,
this.ismFactory.multiProvider,
this.ismFactory.getContracts(chain),
);
if (!matches) {
const ismReader = new EvmIsmReader(this.ismFactory.multiProvider, chain);
const derivedConfig = await ismReader.deriveIsmConfig(actualIsmAddress);
const violation: MailboxViolation = {
type: CoreViolationType.Mailbox,
subType: MailboxViolationType.DefaultIsm,
contract: mailbox,
chain,
actual: actualIsm,
actual: derivedConfig,
expected: config.defaultIsm,
};
this.addViolation(violation);

@ -2,6 +2,7 @@ import { addressToBytes32, assert, eqAddress } from '@hyperlane-xyz/utils';
import { HyperlaneFactories } from '../contracts/types.js';
import { HyperlaneAppChecker } from '../deploy/HyperlaneAppChecker.js';
import { EvmIsmReader } from '../ism/EvmIsmReader.js';
import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js';
import { moduleMatchesConfig } from '../ism/utils.js';
import { MultiProvider } from '../providers/MultiProvider.js';
@ -74,7 +75,7 @@ export class HyperlaneRouterChecker<
}
if (config.interchainSecurityModule) {
const actual = await router.interchainSecurityModule();
const actualIsmAddress = await router.interchainSecurityModule();
if (
typeof config.interchainSecurityModule !== 'string' &&
!this.ismFactory
@ -86,18 +87,21 @@ export class HyperlaneRouterChecker<
const matches = await moduleMatchesConfig(
chain,
actual,
actualIsmAddress,
config.interchainSecurityModule,
this.multiProvider,
this.ismFactory?.chainMap[chain] ?? ({} as any),
);
if (!matches) {
const ismReader = new EvmIsmReader(this.multiProvider, chain);
const derivedConfig = await ismReader.deriveIsmConfig(actualIsmAddress);
const violation: ClientViolation = {
chain,
type: ClientViolationType.InterchainSecurityModule,
contract: router,
actual,
actual: derivedConfig,
expected: config.interchainSecurityModule,
description: `ISM config does not match deployed ISM`,
};

@ -7468,6 +7468,7 @@ __metadata:
asn1.js: "npm:5.4.1"
aws-kms-ethers-signer: "npm:^0.1.3"
chai: "npm:^4.3.6"
deep-object-diff: "npm:^1.1.9"
dotenv: "npm:^10.0.0"
ethereum-waffle: "npm:^4.0.10"
ethers: "npm:^5.7.2"
@ -16397,6 +16398,13 @@ __metadata:
languageName: node
linkType: hard
"deep-object-diff@npm:^1.1.9":
version: 1.1.9
resolution: "deep-object-diff@npm:1.1.9"
checksum: b9771cc1ca08a34e408309eaab967bd2ab697684abdfa1262f4283ced8230a9ace966322f356364ff71a785c6e9cc356b7596582e900da5726e6b87d4b2a1463
languageName: node
linkType: hard
"deepmerge@npm:^4.2.2":
version: 4.3.1
resolution: "deepmerge@npm:4.3.1"

Loading…
Cancel
Save