diff --git a/.changeset/sixty-plants-tie.md b/.changeset/sixty-plants-tie.md new file mode 100644 index 000000000..117b32c16 --- /dev/null +++ b/.changeset/sixty-plants-tie.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/sdk': patch +--- + +Added decimal consistency checks to the Token checker diff --git a/typescript/infra/scripts/check/check-warp-deploy.ts b/typescript/infra/scripts/check/check-warp-deploy.ts index f32d53c2b..2cc11d8f0 100644 --- a/typescript/infra/scripts/check/check-warp-deploy.ts +++ b/typescript/infra/scripts/check/check-warp-deploy.ts @@ -3,7 +3,7 @@ import { Gauge, Registry } from 'prom-client'; import { warpConfigGetterMap } from '../../config/warp.js'; import { submitMetrics } from '../../src/utils/metrics.js'; -import { Modules } from '../agent-utils.js'; +import { Modules, getWarpRouteIdsInteractive } from '../agent-utils.js'; import { getEnvironmentConfig } from '../core-utils.js'; import { @@ -14,8 +14,15 @@ import { } from './check-utils.js'; async function main() { - const { environment, asDeployer, chains, fork, context, pushMetrics } = - await getCheckWarpDeployArgs().argv; + const { + environment, + asDeployer, + chains, + fork, + context, + pushMetrics, + interactive, + } = await getCheckWarpDeployArgs().argv; const envConfig = getEnvironmentConfig(environment); // Get the multiprovider once to avoid recreating it for each warp route @@ -29,8 +36,13 @@ async function main() { const failedWarpRoutesChecks: string[] = []; + let warpIdsToCheck = Object.keys(warpConfigGetterMap); + if (interactive) { + warpIdsToCheck = await getWarpRouteIdsInteractive(); + } + // TODO: consider retrying this if check throws an error - for (const warpRouteId of Object.keys(warpConfigGetterMap)) { + for (const warpRouteId of warpIdsToCheck) { console.log(`\nChecking warp route ${warpRouteId}...`); const warpModule = Modules.WARP; diff --git a/typescript/sdk/src/deploy/HyperlaneAppChecker.ts b/typescript/sdk/src/deploy/HyperlaneAppChecker.ts index 55f288f08..e1f5e06b2 100644 --- a/typescript/sdk/src/deploy/HyperlaneAppChecker.ts +++ b/typescript/sdk/src/deploy/HyperlaneAppChecker.ts @@ -48,11 +48,7 @@ export abstract class HyperlaneAppChecker< async check(chainsToCheck?: ChainName[]): Promise { // Get all EVM chains from config - const evmChains = Object.keys(this.configMap).filter( - (chain) => - this.multiProvider.getChainMetadata(chain).protocol === - ProtocolType.Ethereum, - ); + const evmChains = this.getEvmChains(); // Mark any EVM chains that are not deployed const appChains = this.app.chains(); @@ -82,6 +78,14 @@ export abstract class HyperlaneAppChecker< ); } + getEvmChains(): ChainName[] { + return Object.keys(this.configMap).filter( + (chain) => + this.multiProvider.getChainMetadata(chain).protocol === + ProtocolType.Ethereum, + ); + } + addViolation(violation: CheckerViolation): void { if (violation.type === ViolationType.BytecodeMismatch) { rootLogger.warn({ violation }, `Found bytecode mismatch. Ignoring...`); diff --git a/typescript/sdk/src/token/checker.ts b/typescript/sdk/src/token/checker.ts index 156658ddc..54ed772d0 100644 --- a/typescript/sdk/src/token/checker.ts +++ b/typescript/sdk/src/token/checker.ts @@ -5,8 +5,9 @@ import { ERC20__factory, HypERC20Collateral, IXERC20Lockbox__factory, + TokenRouter, } from '@hyperlane-xyz/core'; -import { eqAddress } from '@hyperlane-xyz/utils'; +import { eqAddress, objMap } from '@hyperlane-xyz/utils'; import { TokenMismatchViolation } from '../deploy/types.js'; import { ProxiedRouterChecker } from '../router/ProxiedRouterChecker.js'; @@ -66,6 +67,30 @@ export class HypERC20Checker extends ProxiedRouterChecker< const expectedConfig = this.configMap[chain]; const hypToken = this.app.router(this.app.getContracts(chain)); + + // Check all actual decimals are consistent + const actualChainDecimals = await this.getEvmActualDecimals(); + this.checkDecimalConsistency( + chain, + hypToken, + actualChainDecimals, + 'actual', + true, + ); + + // Check all config decimals are consistent as well + const configDecimals = objMap( + this.configMap, + (_chain, config) => config.decimals, + ); + this.checkDecimalConsistency( + chain, + hypToken, + configDecimals, + 'config', + false, + ); + if (isNativeConfig(expectedConfig)) { try { await this.multiProvider.estimateGas(chain, { @@ -86,37 +111,119 @@ export class HypERC20Checker extends ProxiedRouterChecker< } else if (isSyntheticConfig(expectedConfig)) { await checkERC20(hypToken as unknown as ERC20, expectedConfig); } else if (isCollateralConfig(expectedConfig)) { + const collateralToken = await this.getCollateralToken(chain); + const actualToken = await ( + hypToken as unknown as HypERC20Collateral + ).wrappedToken(); + if (!eqAddress(collateralToken.address, actualToken)) { + const violation: TokenMismatchViolation = { + type: 'CollateralTokenMismatch', + chain, + expected: collateralToken.address, + actual: actualToken, + tokenAddress: hypToken.address, + }; + this.addViolation(violation); + } + } + } + + private cachedAllActualDecimals: Record | undefined = + undefined; + + async getEvmActualDecimals(): Promise> { + if (this.cachedAllActualDecimals) { + return this.cachedAllActualDecimals; + } + const entries = await Promise.all( + this.getEvmChains().map(async (chain) => { + const token = this.app.router(this.app.getContracts(chain)); + return [chain, await this.getActualDecimals(chain, token)]; + }), + ); + + this.cachedAllActualDecimals = Object.fromEntries(entries); + + return this.cachedAllActualDecimals!; + } + + async getActualDecimals( + chain: ChainName, + hypToken: TokenRouter, + ): Promise { + const expectedConfig = this.configMap[chain]; + let decimals: number | undefined = undefined; + + if (isNativeConfig(expectedConfig)) { + decimals = + this.multiProvider.getChainMetadata(chain).nativeToken?.decimals; + } else if (isSyntheticConfig(expectedConfig)) { + decimals = await (hypToken as unknown as ERC20).decimals(); + } else if (isCollateralConfig(expectedConfig)) { + const collateralToken = await this.getCollateralToken(chain); + decimals = await collateralToken.decimals(); + } + + if (decimals === undefined) { + throw new Error('Actual decimals not found'); + } + + return decimals; + } + + async getCollateralToken(chain: ChainName): Promise { + const expectedConfig = this.configMap[chain]; + let collateralToken: ERC20 | undefined = undefined; + + if (isCollateralConfig(expectedConfig)) { const provider = this.multiProvider.getProvider(chain); - let collateralToken: ERC20; if (expectedConfig.type === TokenType.XERC20Lockbox) { const collateralTokenAddress = await IXERC20Lockbox__factory.connect( expectedConfig.token, provider, ).callStatic.ERC20(); - collateralToken = await ERC20__factory.connect( + collateralToken = ERC20__factory.connect( collateralTokenAddress, provider, ); } else { - collateralToken = await ERC20__factory.connect( + collateralToken = ERC20__factory.connect( expectedConfig.token, provider, ); } - const actualToken = await ( - hypToken as unknown as HypERC20Collateral - ).wrappedToken(); - if (!eqAddress(collateralToken.address, actualToken)) { - const violation: TokenMismatchViolation = { - type: 'CollateralTokenMismatch', - chain, - expected: collateralToken.address, - actual: actualToken, - tokenAddress: hypToken.address, - }; - this.addViolation(violation); - } + } + if (!collateralToken) { + throw new Error('Collateral token not found'); + } + return collateralToken; + } + + checkDecimalConsistency( + chain: ChainName, + hypToken: TokenRouter, + chainDecimals: Record, + decimalType: string, + nonEmpty: boolean, + ) { + const uniqueChainDecimals = new Set( + Object.values(chainDecimals).filter((decimals) => !!decimals), + ); + if ( + uniqueChainDecimals.size > 1 || + (nonEmpty && uniqueChainDecimals.size === 0) + ) { + const violation: TokenMismatchViolation = { + type: 'TokenDecimalsMismatch', + chain, + expected: `${ + nonEmpty ? 'non-empty and ' : '' + }consistent ${decimalType} decimals`, + actual: JSON.stringify(chainDecimals), + tokenAddress: hypToken.address, + }; + this.addViolation(violation); } } }