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 - Manualpull/4723/head
parent
7bfc7ec81c
commit
32d0a67c21
@ -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`); |
||||
} |
@ -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; |
||||
} |
@ -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'); |
||||
} |
Loading…
Reference in new issue