feat: ensure consistent decimals in Token checker (#4884)

### Description

- ensures actual (i.e. looking at onchain info, or chain metadata for
native warp routes) decimals are consistent and nonzero
- ensures any configured decimals are also consistent (but doesn't
require them to be present)
- cobbled together pretty quickly - not sure if there's a more elegant
way of having non-chain-specific checks

### Drive-by changes

<!--
Are there any minor or drive-by changes also included?
-->

### Related issues

<!--
- Fixes #[issue number here]
-->

### Backward compatibility

<!--
Are these changes backward compatible? Are there any infrastructure
implications, e.g. changes that would prohibit deploying older commits
using this infra tooling?

Yes/No
-->

### Testing

<!--
What kind of testing have these changes undergone?

None/Manual/Unit Tests
-->

---------

Co-authored-by: Paul Balaji <10051819+paulbalaji@users.noreply.github.com>
pull/4918/merge
Trevor Porter 3 days ago committed by GitHub
parent 1ca8574510
commit 665a7b8d89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/sixty-plants-tie.md
  2. 20
      typescript/infra/scripts/check/check-warp-deploy.ts
  3. 14
      typescript/sdk/src/deploy/HyperlaneAppChecker.ts
  4. 131
      typescript/sdk/src/token/checker.ts

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/sdk': patch
---
Added decimal consistency checks to the Token checker

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

@ -48,11 +48,7 @@ export abstract class HyperlaneAppChecker<
async check(chainsToCheck?: ChainName[]): Promise<void[]> {
// 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...`);

@ -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<ChainName, number> | undefined =
undefined;
async getEvmActualDecimals(): Promise<Record<ChainName, number>> {
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<number> {
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<ERC20> {
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)) {
}
if (!collateralToken) {
throw new Error('Collateral token not found');
}
return collateralToken;
}
checkDecimalConsistency(
chain: ChainName,
hypToken: TokenRouter,
chainDecimals: Record<ChainName, number | undefined>,
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: 'CollateralTokenMismatch',
type: 'TokenDecimalsMismatch',
chain,
expected: collateralToken.address,
actual: actualToken,
expected: `${
nonEmpty ? 'non-empty and ' : ''
}consistent ${decimalType} decimals`,
actual: JSON.stringify(chainDecimals),
tokenAddress: hypToken.address,
};
this.addViolation(violation);
}
}
}
}

Loading…
Cancel
Save