feat: cli warp route checker (#4667)

### Description

This PR implements the `warp check` command to compare on-chain warp
deployments with provided configuration files

### Drive-by changes

- updated the `inputFileCommandOption` to be a function for defining cli
input file args
- defined the `DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH` to avoid
hardcoding the `./configs/warp-route-deployment.yaml` file path
- implemented the `logCommandHeader` function to format the command
header and avoid always having to manually log the `---` separator in
command outputs
- implements the `getWarpCoreConfigOrExit` to get the warp core
configuration from either the registry or a user-defined path and exit
early if no input value is provided
- Updated the `IsmConfigSchema`s to include the `BaseIsmConfigSchema`
because when parsing config files the address field was not included in
the parsed object as it wasn't defined on the type

Example output

![image](https://github.com/user-attachments/assets/07821a13-d2e2-4b73-b493-9a2c2829a7b3)


![image](https://github.com/user-attachments/assets/768d724f-c96e-4ff5-9c4d-332560c57626)


![image](https://github.com/user-attachments/assets/f92df7c5-acac-4ff7-974b-0334e4a221ab)

### Related issues

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

### Backward compatibility

- Yes

### Testing

- Manual
pull/4723/head
xeno097 1 week ago committed by GitHub
parent 7bfc7ec81c
commit 32d0a67c21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/grumpy-ears-relate.md
  2. 41
      typescript/cli/src/check/warp.ts
  3. 8
      typescript/cli/src/commands/config.ts
  4. 26
      typescript/cli/src/commands/options.ts
  5. 186
      typescript/cli/src/commands/warp.ts
  6. 4
      typescript/cli/src/logger.ts
  7. 117
      typescript/cli/src/read/warp.ts
  8. 35
      typescript/cli/src/utils/input.ts
  9. 56
      typescript/cli/src/utils/output.ts
  10. 2
      typescript/sdk/src/index.ts
  11. 2
      typescript/utils/src/index.ts
  12. 136
      typescript/utils/src/objects.test.ts
  13. 101
      typescript/utils/src/objects.ts

@ -0,0 +1,6 @@
---
'@hyperlane-xyz/cli': minor
'@hyperlane-xyz/sdk': minor
---
Adds the warp check command to compare warp routes config files with on chain warp route deployments

@ -0,0 +1,41 @@
import { stringify as yamlStringify } from 'yaml';
import { WarpRouteDeployConfig, normalizeConfig } from '@hyperlane-xyz/sdk';
import { ObjectDiff, diffObjMerge } from '@hyperlane-xyz/utils';
import { log, logGreen } from '../logger.js';
import '../utils/output.js';
import { formatYamlViolationsOutput } from '../utils/output.js';
export async function runWarpRouteCheck({
warpRouteConfig,
onChainWarpConfig,
}: {
warpRouteConfig: WarpRouteDeployConfig;
onChainWarpConfig: WarpRouteDeployConfig;
}): Promise<void> {
// Go through each chain and only add to the output the chains that have mismatches
const [violations, isInvalid] = Object.keys(warpRouteConfig).reduce(
(acc, chain) => {
const { mergedObject, isInvalid } = diffObjMerge(
normalizeConfig(onChainWarpConfig[chain]),
normalizeConfig(warpRouteConfig[chain]),
);
if (isInvalid) {
acc[0][chain] = mergedObject;
acc[1] ||= isInvalid;
}
return acc;
},
[{}, false] as [{ [index: string]: ObjectDiff }, boolean],
);
if (isInvalid) {
log(formatYamlViolationsOutput(yamlStringify(violations, null, 2)));
process.exit(1);
}
logGreen(`No violations found`);
}

@ -41,7 +41,7 @@ const validateChainCommand: CommandModuleWithContext<{ path: string }> = {
command: 'chain',
describe: 'Validate a chain config file',
builder: {
path: inputFileCommandOption,
path: inputFileCommandOption(),
},
handler: async ({ path }) => {
readChainConfigs(path);
@ -54,7 +54,7 @@ const validateIsmCommand: CommandModuleWithContext<{ path: string }> = {
command: 'ism',
describe: 'Validate the basic ISM config file',
builder: {
path: inputFileCommandOption,
path: inputFileCommandOption(),
},
handler: async ({ path }) => {
readMultisigConfig(path);
@ -67,7 +67,7 @@ const validateIsmAdvancedCommand: CommandModuleWithContext<{ path: string }> = {
command: 'ism-advanced',
describe: 'Validate the advanced ISM config file',
builder: {
path: inputFileCommandOption,
path: inputFileCommandOption(),
},
handler: async ({ path }) => {
readIsmConfig(path);
@ -80,7 +80,7 @@ const validateWarpCommand: CommandModuleWithContext<{ path: string }> = {
command: 'warp',
describe: 'Validate a Warp Route deployment config file',
builder: {
path: inputFileCommandOption,
path: inputFileCommandOption(),
},
handler: async ({ path }) => {
await readWarpRouteDeployConfig(path);

@ -91,11 +91,14 @@ export const hookCommandOption: Options = {
'A path to a JSON or YAML file with Hook configs (for every chain)',
};
export const DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH =
'./configs/warp-route-deployment.yaml';
export const warpDeploymentConfigCommandOption: Options = {
type: 'string',
description:
'A path to a JSON or YAML file with a warp route deployment config.',
default: './configs/warp-route-deployment.yaml',
default: DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH,
alias: 'wd',
};
@ -134,12 +137,23 @@ export const outputFileCommandOption = (
demandOption,
});
export const inputFileCommandOption: Options = {
interface InputFileCommandOptionConfig
extends Pick<Options, 'demandOption' | 'alias' | 'description'> {
defaultPath?: string;
}
export const inputFileCommandOption = ({
defaultPath,
demandOption = true,
description = 'Input file path',
alias = 'i',
}: InputFileCommandOptionConfig = {}): Options => ({
type: 'string',
description: 'Input file path',
alias: 'i',
demandOption: true,
};
description,
default: defaultPath,
alias,
demandOption,
});
export const fromAddressCommandOption: Options = {
type: 'string',

@ -1,24 +1,11 @@
import { ethers } from 'ethers';
import { stringify as yamlStringify } from 'yaml';
import { CommandModule } from 'yargs';
import {
HypXERC20Lockbox__factory,
HypXERC20__factory,
IXERC20__factory,
} from '@hyperlane-xyz/core';
import {
ChainMap,
ChainSubmissionStrategySchema,
EvmERC20WarpRouteReader,
TokenStandard,
WarpCoreConfig,
} from '@hyperlane-xyz/sdk';
import { objMap, promiseObjAll } from '@hyperlane-xyz/utils';
import { ChainSubmissionStrategySchema } from '@hyperlane-xyz/sdk';
import { runWarpRouteCheck } from '../check/warp.js';
import {
createWarpRouteDeployConfig,
readWarpCoreConfig,
readWarpRouteDeployConfig,
} from '../config/warp.js';
import {
@ -27,7 +14,8 @@ import {
} from '../context/types.js';
import { evaluateIfDryRunFailure } from '../deploy/dry-run.js';
import { runWarpRouteApply, runWarpRouteDeploy } from '../deploy/warp.js';
import { log, logGray, logGreen, logRed, logTable } from '../logger.js';
import { log, logCommandHeader, logGreen } from '../logger.js';
import { runWarpRouteRead } from '../read/warp.js';
import { sendTestTransfer } from '../send/transfer.js';
import {
indentYamlOrJson,
@ -35,13 +23,15 @@ import {
removeEndingSlash,
writeYamlOrJson,
} from '../utils/files.js';
import { selectRegistryWarpRoute } from '../utils/tokens.js';
import { getWarpCoreConfigOrExit } from '../utils/input.js';
import {
DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH,
addressCommandOption,
chainCommandOption,
dryRunCommandOption,
fromAddressCommandOption,
inputFileCommandOption,
outputFileCommandOption,
strategyCommandOption,
symbolCommandOption,
@ -59,6 +49,7 @@ export const warpCommand: CommandModule = {
builder: (yargs) =>
yargs
.command(apply)
.command(check)
.command(deploy)
.command(init)
.command(read)
@ -104,17 +95,14 @@ export const apply: CommandModuleWithWriteContext<{
strategy: strategyUrl,
receiptsDir,
}) => {
logGray(`Hyperlane Warp Apply`);
logGray('--------------------'); // @TODO consider creating a helper function for these dashes
let warpCoreConfig: WarpCoreConfig;
if (symbol) {
warpCoreConfig = await selectRegistryWarpRoute(context.registry, symbol);
} else if (warp) {
warpCoreConfig = readWarpCoreConfig(warp);
} else {
logRed(`Please specify either a symbol or warp config`);
process.exit(0);
}
logCommandHeader('Hyperlane Warp Apply');
const warpCoreConfig = await getWarpCoreConfigOrExit({
symbol,
warp,
context,
});
if (strategyUrl)
ChainSubmissionStrategySchema.parse(readYamlOrJson(strategyUrl));
const warpDeployConfig = await readWarpRouteDeployConfig(config);
@ -143,8 +131,9 @@ export const deploy: CommandModuleWithWriteContext<{
'from-address': fromAddressCommandOption,
},
handler: async ({ context, config, dryRun }) => {
logGray(`Hyperlane Warp Route Deployment${dryRun ? ' Dry-Run' : ''}`);
logGray('------------------------------------------------');
logCommandHeader(
`Hyperlane Warp Route Deployment${dryRun ? ' Dry-Run' : ''}`,
);
try {
await runWarpRouteDeploy({
@ -171,11 +160,10 @@ export const init: CommandModuleWithContext<{
describe: 'Create an advanced ISM',
default: false,
},
out: outputFileCommandOption('./configs/warp-route-deployment.yaml'),
out: outputFileCommandOption(DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH),
},
handler: async ({ context, advanced, out }) => {
logGray('Hyperlane Warp Configure');
logGray('------------------------');
logCommandHeader('Hyperlane Warp Configure');
await createWarpRouteDeployConfig({
context,
@ -208,7 +196,7 @@ export const read: CommandModuleWithContext<{
false,
),
config: outputFileCommandOption(
'./configs/warp-route-deployment.yaml',
DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH,
false,
'The path to output a Warp Config JSON or YAML file.',
),
@ -220,75 +208,14 @@ export const read: CommandModuleWithContext<{
config: configFilePath,
symbol,
}) => {
logGray('Hyperlane Warp Reader');
logGray('---------------------');
const { multiProvider } = context;
let addresses: ChainMap<string>;
if (symbol) {
const warpCoreConfig = await selectRegistryWarpRoute(
context.registry,
symbol,
);
// TODO: merge with XERC20TokenAdapter and WarpRouteReader
const xerc20Limits = await Promise.all(
warpCoreConfig.tokens
.filter(
(t) =>
t.standard === TokenStandard.EvmHypXERC20 ||
t.standard === TokenStandard.EvmHypXERC20Lockbox,
)
.map(async (t) => {
const provider = multiProvider.getProvider(t.chainName);
const router = t.addressOrDenom!;
const xerc20Address =
t.standard === TokenStandard.EvmHypXERC20Lockbox
? await HypXERC20Lockbox__factory.connect(
router,
provider,
).xERC20()
: await HypXERC20__factory.connect(
router,
provider,
).wrappedToken();
logCommandHeader('Hyperlane Warp Reader');
const xerc20 = IXERC20__factory.connect(xerc20Address, provider);
const mint = await xerc20.mintingCurrentLimitOf(router);
const burn = await xerc20.burningCurrentLimitOf(router);
const formattedLimits = objMap({ mint, burn }, (_, v) =>
ethers.utils.formatUnits(v, t.decimals),
);
return [t.chainName, formattedLimits];
}),
);
if (xerc20Limits.length > 0) {
logGray('xERC20 Limits:');
logTable(Object.fromEntries(xerc20Limits));
}
addresses = Object.fromEntries(
warpCoreConfig.tokens.map((t) => [t.chainName, t.addressOrDenom!]),
);
} else if (chain && address) {
addresses = {
[chain]: address,
};
} else {
logGreen(`Please specify either a symbol or chain and address`);
process.exit(0);
}
const config = await promiseObjAll(
objMap(addresses, async (chain, address) =>
new EvmERC20WarpRouteReader(multiProvider, chain).deriveWarpRouteConfig(
address,
),
),
);
const config = await runWarpRouteRead({
context,
chain,
address,
symbol,
});
if (configFilePath) {
writeYamlOrJson(configFilePath, config, 'yaml');
@ -346,15 +273,11 @@ const send: CommandModuleWithWriteContext<
amount,
recipient,
}) => {
let warpCoreConfig: WarpCoreConfig;
if (symbol) {
warpCoreConfig = await selectRegistryWarpRoute(context.registry, symbol);
} else if (warp) {
warpCoreConfig = readWarpCoreConfig(warp);
} else {
logRed(`Please specify either a symbol or warp config`);
process.exit(0);
}
const warpCoreConfig = await getWarpCoreConfigOrExit({
symbol,
warp,
context,
});
await sendTestTransfer({
context,
@ -370,3 +293,44 @@ const send: CommandModuleWithWriteContext<
process.exit(0);
},
};
export const check: CommandModuleWithContext<{
config: string;
symbol?: string;
warp?: string;
}> = {
command: 'check',
describe:
'Verifies that a warp route configuration matches the on chain configuration.',
builder: {
symbol: {
...symbolCommandOption,
demandOption: false,
},
warp: {
...warpCoreConfigCommandOption,
demandOption: false,
},
config: inputFileCommandOption({
defaultPath: DEFAULT_WARP_ROUTE_DEPLOYMENT_CONFIG_PATH,
description: 'The path to a warp route deployment configuration file',
}),
},
handler: async ({ context, config, symbol, warp }) => {
logCommandHeader('Hyperlane Warp Check');
const warpRouteConfig = await readWarpRouteDeployConfig(config, context);
const onChainWarpConfig = await runWarpRouteRead({
context,
warp,
symbol,
});
await runWarpRouteCheck({
onChainWarpConfig,
warpRouteConfig,
});
process.exit(0);
},
};

@ -57,5 +57,9 @@ export const errorRed = (...args: any) => logColor('error', chalk.red, ...args);
export const logDebug = (msg: string, ...args: any) =>
logger.debug(msg, ...args);
export const logCommandHeader = (msg: string) => {
logGray(`${msg}\n${'_'.repeat(msg.length)}`);
};
// No support for table in pino so print directly to console
export const logTable = (...args: any) => console.table(...args);

@ -0,0 +1,117 @@
import { ethers } from 'ethers';
import {
HypXERC20Lockbox__factory,
HypXERC20__factory,
IXERC20__factory,
} from '@hyperlane-xyz/core';
import {
ChainMap,
ChainName,
EvmERC20WarpRouteReader,
TokenStandard,
} from '@hyperlane-xyz/sdk';
import { isAddressEvm, objMap, promiseObjAll } from '@hyperlane-xyz/utils';
import { CommandContext } from '../context/types.js';
import { logGray, logRed, logTable } from '../logger.js';
import { getWarpCoreConfigOrExit } from '../utils/input.js';
export async function runWarpRouteRead({
context,
chain,
address,
warp,
symbol,
}: {
context: CommandContext;
chain?: ChainName;
warp?: string;
address?: string;
symbol?: string;
}): Promise<Record<ChainName, any>> {
const { multiProvider } = context;
let addresses: ChainMap<string>;
if (symbol || warp) {
const warpCoreConfig = await getWarpCoreConfigOrExit({
context,
warp,
symbol,
});
// TODO: merge with XERC20TokenAdapter and WarpRouteReader
const xerc20Limits = await Promise.all(
warpCoreConfig.tokens
.filter(
(t) =>
t.standard === TokenStandard.EvmHypXERC20 ||
t.standard === TokenStandard.EvmHypXERC20Lockbox,
)
.map(async (t) => {
const provider = multiProvider.getProvider(t.chainName);
const router = t.addressOrDenom!;
const xerc20Address =
t.standard === TokenStandard.EvmHypXERC20Lockbox
? await HypXERC20Lockbox__factory.connect(
router,
provider,
).xERC20()
: await HypXERC20__factory.connect(
router,
provider,
).wrappedToken();
const xerc20 = IXERC20__factory.connect(xerc20Address, provider);
const mint = await xerc20.mintingCurrentLimitOf(router);
const burn = await xerc20.burningCurrentLimitOf(router);
const formattedLimits = objMap({ mint, burn }, (_, v) =>
ethers.utils.formatUnits(v, t.decimals),
);
return [t.chainName, formattedLimits];
}),
);
if (xerc20Limits.length > 0) {
logGray('xERC20 Limits:');
logTable(Object.fromEntries(xerc20Limits));
}
addresses = Object.fromEntries(
warpCoreConfig.tokens.map((t) => [t.chainName, t.addressOrDenom!]),
);
} else if (chain && address) {
addresses = {
[chain]: address,
};
} else {
logRed(`Please specify either a symbol, chain and address or warp file`);
process.exit(1);
}
// Check if there any non-EVM chains in the config and exit
const nonEvmChains = Object.entries(addresses)
.filter(([_, address]) => !isAddressEvm(address))
.map(([chain]) => chain);
if (nonEvmChains.length > 0) {
const chainList = nonEvmChains.join(', ');
logRed(
`${chainList} ${
nonEvmChains.length > 1 ? 'are' : 'is'
} non-EVM and not compatible with the cli`,
);
process.exit(1);
}
const config = await promiseObjAll(
objMap(addresses, async (chain, address) =>
new EvmERC20WarpRouteReader(multiProvider, chain).deriveWarpRouteConfig(
address,
),
),
);
return config;
}

@ -18,9 +18,14 @@ import type { PartialDeep } from '@inquirer/type';
import ansiEscapes from 'ansi-escapes';
import chalk from 'chalk';
import { logGray } from '../logger.js';
import { WarpCoreConfig } from '@hyperlane-xyz/sdk';
import { readWarpCoreConfig } from '../config/warp.js';
import { CommandContext } from '../context/types.js';
import { logGray, logRed } from '../logger.js';
import { indentYamlOrJson } from './files.js';
import { selectRegistryWarpRoute } from './tokens.js';
export async function detectAndConfirmOrPrompt(
detect: () => Promise<string | undefined>,
@ -72,6 +77,34 @@ export async function inputWithInfo({
return answer;
}
/**
* Gets a {@link WarpCoreConfig} based on the provided path or prompts the user to choose one:
* - if `symbol` is provided the user will have to select one of the available warp routes.
* - if `warp` is provided the config will be read by the provided file path.
* - if none is provided the CLI will exit.
*/
export async function getWarpCoreConfigOrExit({
context,
symbol,
warp,
}: {
context: CommandContext;
symbol?: string;
warp?: string;
}): Promise<WarpCoreConfig> {
let warpCoreConfig: WarpCoreConfig;
if (symbol) {
warpCoreConfig = await selectRegistryWarpRoute(context.registry, symbol);
} else if (warp) {
warpCoreConfig = readWarpCoreConfig(warp);
} else {
logRed(`Please specify either a symbol or warp config`);
process.exit(0);
}
return warpCoreConfig;
}
/**
* Searchable checkbox code
*

@ -0,0 +1,56 @@
import chalk from 'chalk';
export enum ViolationDiffType {
None,
Expected,
Actual,
}
type FormatterByDiffType = Record<ViolationDiffType, (text: string) => string>;
const defaultDiffFormatter: FormatterByDiffType = {
[ViolationDiffType.Actual]: chalk.red,
[ViolationDiffType.Expected]: chalk.green,
[ViolationDiffType.None]: (text: string) => text,
};
/**
* Takes a yaml formatted string and highlights differences by looking at `expected` and `actual` properties.
*/
export function formatYamlViolationsOutput(
yamlString: string,
formatters: FormatterByDiffType = defaultDiffFormatter,
): string {
const lines = yamlString.split('\n');
let curr: ViolationDiffType = ViolationDiffType.None;
let lastDiffIndent = 0;
const highlightedLines = lines.map((line) => {
// Get how many white space/tabs we have before the property name
const match = line.match(/^(\s*)/);
const currentIndent = match ? match[0].length : 0;
let formattedLine = line;
// if the current indentation is smaller than the previous diff one
// we just got out of a diff property and we reset the formatting
if (currentIndent < lastDiffIndent) {
curr = ViolationDiffType.None;
}
if (line.includes('expected:')) {
lastDiffIndent = currentIndent;
curr = ViolationDiffType.Expected;
formattedLine = line.replace('expected:', 'EXPECTED:');
}
if (line.includes('actual:')) {
lastDiffIndent = currentIndent;
curr = ViolationDiffType.Actual;
formattedLine = line.replace('actual:', 'ACTUAL:');
}
return formatters[curr](formattedLine);
});
return highlightedLines.join('\n');
}

@ -487,7 +487,7 @@ export {
setFork,
stopImpersonatingAccount,
} from './utils/fork.js';
export { multisigIsmVerificationCost } from './utils/ism.js';
export { multisigIsmVerificationCost, normalizeConfig } from './utils/ism.js';
export { MultiGeneric } from './utils/MultiGeneric.js';
export {
SealevelAccountDataWrapper,

@ -119,6 +119,8 @@ export {
pick,
promiseObjAll,
stringifyObject,
diffObjMerge,
ObjectDiff,
} from './objects.js';
export { Result, failure, success } from './result.js';
export { difference, setEquality, symmetricDifference } from './sets.js';

@ -1,6 +1,12 @@
import { expect } from 'chai';
import { deepCopy, deepEquals, objMerge, objOmit } from './objects.js';
import {
deepCopy,
deepEquals,
diffObjMerge,
objMerge,
objOmit,
} from './objects.js';
describe('Object utilities', () => {
it('deepEquals', () => {
@ -67,4 +73,132 @@ describe('Object utilities', () => {
const omitted1_2 = objOmit(obj1, obj2, 10, false);
expect(omitted1_2).to.eql({ a: 1, b: { d: 'string' } });
});
describe('diffObjMerge', () => {
it('should merge objects with equal values', () => {
const actual = { a: 1, b: 2 };
const expected = { a: 1, b: 2 };
const result = diffObjMerge(actual, expected);
expect(result).to.eql({
isInvalid: false,
mergedObject: { a: 1, b: 2 },
});
});
it('should return a diff for objects with different values', () => {
const actual = { a: 1, b: 2 };
const expected = { a: 1, b: 3 };
const result = diffObjMerge(actual, expected);
expect(result).to.eql({
isInvalid: true,
mergedObject: {
a: 1,
b: { actual: 2, expected: 3 },
},
});
});
it('should detect missing fields in the top level object', () => {
const actual = { a: 1 };
const expected = { a: 1, b: 3 };
const result = diffObjMerge(actual, expected);
expect(result).to.eql({
isInvalid: true,
mergedObject: {
a: 1,
b: { actual: '', expected: 3 },
},
});
});
it('should detect extra fields in the top level object', () => {
const actual = { a: 1, b: 2 };
const expected = { a: 1 };
const result = diffObjMerge(actual, expected);
expect(result).to.eql({
isInvalid: true,
mergedObject: {
a: 1,
b: { actual: 2, expected: '' },
},
});
});
it('should merge nested objects and show differences', () => {
const actual = { a: 1, b: { c: 2, d: 4 } };
const expected = { a: 1, b: { c: 2, d: 3 } };
const result = diffObjMerge(actual, expected);
expect(result).to.eql({
isInvalid: true,
mergedObject: {
a: 1,
b: {
c: 2,
d: { actual: 4, expected: 3 },
},
},
});
});
it('should throw an error when maxDepth is exceeded', () => {
const actual = { a: { b: { c: { d: { e: 5 } } } } };
const expected = { a: { b: { c: { d: { e: 5 } } } } };
expect(() => diffObjMerge(actual, expected, 3)).to.Throw(
'diffObjMerge tried to go too deep',
);
});
it('should merge arrays of equal length and show the diffs', () => {
const actual = [1, 2, 3];
const expected = [1, 2, 4];
const result = diffObjMerge(actual, expected);
expect(result).to.eql({
isInvalid: true,
mergedObject: [1, 2, { actual: 3, expected: 4 }],
});
});
it('should return a diff for arrays of different lengths', () => {
const actual = [1, 2];
const expected = [1, 2, 3];
const result = diffObjMerge(actual, expected);
expect(result).to.eql({
isInvalid: true,
mergedObject: {
actual,
expected,
},
});
});
it('should handle null and undefined values properly', () => {
const actual = { a: null, b: 2 };
const expected = { a: undefined, b: 2 };
const result = diffObjMerge(actual, expected);
expect(result).to.eql({
isInvalid: false,
mergedObject: {
a: undefined,
b: 2,
},
});
});
});
});

@ -2,6 +2,7 @@ import { cloneDeep, isEqual } from 'lodash-es';
import { stringify as yamlStringify } from 'yaml';
import { ethersBigNumberSerializer } from './logging.js';
import { isNullish } from './typeof.js';
import { assert } from './validation.js';
export function isObject(item: any) {
@ -216,3 +217,103 @@ export function stringifyObject(
}
return yamlStringify(JSON.parse(json), null, space);
}
interface ObjectDiffOutput {
actual: any;
expected: any;
}
export type ObjectDiff =
| {
[key: string]: ObjectDiffOutput | ObjectDiff;
}
| ObjectDiff[]
| undefined;
/**
* Merges 2 objects showing any difference in value for common fields.
*/
export function diffObjMerge(
actual: Record<string, any>,
expected: Record<string, any>,
maxDepth = 10,
): {
mergedObject: ObjectDiff;
isInvalid: boolean;
} {
if (maxDepth === 0) {
throw new Error('diffObjMerge tried to go too deep');
}
let isDiff = false;
if (!isObject(actual) && !isObject(expected) && actual === expected) {
return {
isInvalid: isDiff,
mergedObject: actual,
};
}
if (isNullish(actual) && isNullish(expected)) {
return { mergedObject: undefined, isInvalid: isDiff };
}
if (isObject(actual) && isObject(expected)) {
const ret: Record<string, ObjectDiff> = {};
const actualKeys = new Set(Object.keys(actual));
const expectedKeys = new Set(Object.keys(expected));
const allKeys = new Set([...actualKeys, ...expectedKeys]);
for (const key of allKeys.values()) {
if (actualKeys.has(key) && expectedKeys.has(key)) {
const { mergedObject, isInvalid } =
diffObjMerge(actual[key], expected[key], maxDepth - 1) ?? {};
ret[key] = mergedObject;
isDiff ||= isInvalid;
} else if (actualKeys.has(key) && !isNullish(actual[key])) {
ret[key] = {
actual: actual[key],
expected: '' as any,
};
isDiff = true;
} else if (!isNullish(expected[key])) {
ret[key] = {
actual: '' as any,
expected: expected[key],
};
isDiff = true;
}
}
return {
isInvalid: isDiff,
mergedObject: ret,
};
}
// Merge the elements of the array to see if there are any differences
if (
Array.isArray(actual) &&
Array.isArray(expected) &&
actual.length === expected.length
) {
const merged = actual.reduce(
(acc: [ObjectDiff[], boolean], curr, idx) => {
const { isInvalid, mergedObject } = diffObjMerge(curr, expected[idx]);
acc[0].push(mergedObject);
acc[1] ||= isInvalid;
return acc;
},
[[], isDiff],
);
return {
isInvalid: merged[1],
mergedObject: merged[0],
};
}
return {
mergedObject: { expected: expected ?? '', actual: actual ?? '' },
isInvalid: true,
};
}

Loading…
Cancel
Save