diff --git a/.changeset/multicollateral-sdk-cli-monitor.md b/.changeset/multicollateral-sdk-cli-monitor.md new file mode 100644 index 00000000000..ce7335f8c2e --- /dev/null +++ b/.changeset/multicollateral-sdk-cli-monitor.md @@ -0,0 +1,13 @@ +--- +'@hyperlane-xyz/sdk': minor +'@hyperlane-xyz/cli': minor +'@hyperlane-xyz/warp-monitor': minor +--- + +MultiCollateral warp route support was added across the SDK, CLI, and warp monitor. + +SDK: WarpCore gained `transferRemoteTo` flows for multicollateral tokens, including fee quoting, ERC-20 approval, and destination token resolution. EvmWarpModule now handles multicollateral router enrollment/unenrollment with canonical router ID normalization. EvmWarpRouteReader derives multicollateral token config including on-chain scale. A new `EvmMultiCollateralAdapter` provides quote, approve, and transfer operations. + +CLI: `warp deploy` and `warp extend` support multicollateral token types. A new `warp combine` command merges independent warp route configs into a single multicollateral route. `warp send` and `warp check` work with multicollateral routes. + +Warp monitor: Pending-transfer and inventory metrics were added for multicollateral routes, with projected deficit scoped to collateralized routes only. diff --git a/.changeset/rebalancer-sim-exporter.md b/.changeset/rebalancer-sim-exporter.md new file mode 100644 index 00000000000..56f71f2df11 --- /dev/null +++ b/.changeset/rebalancer-sim-exporter.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/rebalancer-sim': minor +--- + +Scenario loading was extracted to a shared `ScenarioLoader` API with `SCENARIOS_DIR` env override support. A new `ResultsExporter` API was added for saving simulation results as JSON and HTML. Path traversal guards were added to both scenario loading and result export paths. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 521f58351d9..347a79000b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2336,6 +2336,9 @@ importers: '@hyperlane-xyz/deploy-sdk': specifier: workspace:* version: link:../deploy-sdk + '@hyperlane-xyz/multicollateral': + specifier: workspace:* + version: link:../../solidity/multicollateral '@hyperlane-xyz/provider-sdk': specifier: workspace:* version: link:../provider-sdk @@ -27660,7 +27663,7 @@ snapshots: debug: 4.4.3(supports-color@5.5.0) minimatch: 9.0.5 semver: 7.7.3 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -32728,7 +32731,7 @@ snapshots: solc: 0.8.26(debug@4.4.3) source-map-support: 0.5.21 stacktrace-parser: 0.1.11 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 tsort: 0.0.1 undici: 5.29.0 uuid: 8.3.2 diff --git a/solidity/eslint.config.mjs b/solidity/eslint.config.mjs index 4090a56ebdb..2676c8571e8 100644 --- a/solidity/eslint.config.mjs +++ b/solidity/eslint.config.mjs @@ -9,8 +9,8 @@ export default [ '**/dist/**/*', '**/lib/**/*', '**/typechain/**/*', - '**/dependencies/**/*', '**/multicollateral/**/*', + '**/dependencies/**/*', '.solcover.js', 'generate-artifact-exports.mjs', ], diff --git a/typescript/Dockerfile.node-service b/typescript/Dockerfile.node-service index 750d6dc9419..20a6ba053a3 100644 --- a/typescript/Dockerfile.node-service +++ b/typescript/Dockerfile.node-service @@ -52,6 +52,7 @@ COPY typescript/tron-sdk/package.json ./typescript/tron-sdk/ COPY typescript/tsconfig/package.json ./typescript/tsconfig/ COPY typescript/eslint-config/package.json ./typescript/eslint-config/ COPY solidity/package.json ./solidity/ +COPY solidity/multicollateral/package.json ./solidity/multicollateral/ COPY solhint-plugin/package.json ./solhint-plugin/ COPY starknet/package.json ./starknet/ @@ -138,4 +139,4 @@ ENV SERVER_PORT=${SERVICE_PORT} EXPOSE 9090 # Run the service from the bundle -CMD ["node", "bundle/index.js"] \ No newline at end of file +CMD ["node", "bundle/index.js"] diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index 6ed94fd56b8..5aef632866a 100644 --- a/typescript/cli/src/commands/warp.ts +++ b/typescript/cli/src/commands/warp.ts @@ -27,7 +27,11 @@ import { type CommandModuleWithWarpDeployContext, type CommandModuleWithWriteContext, } from '../context/types.js'; -import { runWarpRouteApply, runWarpRouteDeploy } from '../deploy/warp.js'; +import { + runWarpRouteApply, + runWarpRouteCombine, + runWarpRouteDeploy, +} from '../deploy/warp.js'; import { runWarpRouteFees } from '../fees/warp.js'; import { runForkCommand } from '../fork/fork.js'; import { @@ -77,6 +81,7 @@ export const warpCommand: CommandModule = { yargs .command(apply) .command(check) + .command(combine) .command(deploy) .command(fork) .command(getFees) @@ -182,6 +187,51 @@ export const deploy: CommandModuleWithWarpDeployContext }, }; +const combine: CommandModuleWithWriteContext<{ + routes: string; + 'output-warp-route-id': string; +}> = { + command: 'combine', + describe: + 'Combine multiple MultiCollateral warp routes, updating deploy configs with cross-route enrolledRouters', + builder: { + routes: { + type: 'string', + description: + 'Comma-separated warp route IDs to combine (e.g., "USDC/eth-arb,USDT/eth-arb")', + demandOption: true, + }, + 'output-warp-route-id': { + type: 'string', + description: + 'Warp route ID for the merged WarpCoreConfig (e.g., MULTI/stableswap)', + demandOption: true, + }, + }, + handler: async ({ context, routes, 'output-warp-route-id': outputId }) => { + logCommandHeader('Hyperlane Warp Combine'); + + const routeIds = routes + .split(',') + .map((r) => r.trim()) + .filter((r) => r.length > 0); + assert( + routeIds.length >= 2, + 'At least 2 route IDs are required to combine', + ); + assert( + outputId.trim().length > 0, + 'Output warp route ID must be non-empty', + ); + await runWarpRouteCombine({ + context, + routeIds, + outputWarpRouteId: outputId, + }); + process.exit(0); + }, +}; + export const init: CommandModuleWithContext<{ advanced: boolean; out: string; @@ -296,6 +346,8 @@ const send: CommandModuleWithWriteContext< recipient?: string; chains?: string[]; skipValidation?: boolean; + sourceToken?: string; + destinationToken?: string; } > = { command: 'send', @@ -322,6 +374,15 @@ const send: CommandModuleWithWriteContext< description: 'Skip transfer validation (e.g., collateral checks)', default: false, }, + 'source-token': { + type: 'string', + description: 'Source token router address (for MultiCollateral routes)', + }, + 'destination-token': { + type: 'string', + description: + 'Destination token router address (for MultiCollateral cross-stablecoin transfers)', + }, }, handler: async ({ context, @@ -337,6 +398,8 @@ const send: CommandModuleWithWriteContext< roundTrip, chains: chainsArg, skipValidation, + sourceToken, + destinationToken, }) => { const warpCoreConfig = await getWarpCoreConfigOrExit({ symbol, @@ -366,10 +429,17 @@ const send: CommandModuleWithWriteContext< `Chain(s) ${[...unsupportedChains].join(', ')} are not part of the warp route.`, ); - chains = - chains.length === 0 - ? [...supportedChains] - : [...intersection(new Set(chains), supportedChains)]; + // When origin & destination are explicitly provided, preserve duplicates + // for same-chain transfers (e.g., origin=anvil2, destination=anvil2). + // Only deduplicate when using --chains or auto-selecting from config. + if (origin && destination) { + chains = [origin, destination]; + } else { + chains = + chains.length === 0 + ? [...supportedChains] + : [...intersection(new Set(chains), supportedChains)]; + } if (roundTrip) { // Appends the reverse of the array, excluding the 1st (e.g. [1,2,3] becomes [1,2,3,2,1]) @@ -388,6 +458,8 @@ const send: CommandModuleWithWriteContext< skipWaitForDelivery: quick, selfRelay: relay, skipValidation, + sourceToken, + destinationToken, }); logGreen( `✅ Successfully sent messages for chains: ${chains.join(' ➡️ ')}`, diff --git a/typescript/cli/src/config/warp.ts b/typescript/cli/src/config/warp.ts index eb9885c556b..f59d966d141 100644 --- a/typescript/cli/src/config/warp.ts +++ b/typescript/cli/src/config/warp.ts @@ -75,6 +75,8 @@ const TYPE_DESCRIPTIONS: Record = { [TokenType.syntheticUri]: '', [TokenType.collateralUri]: '', [TokenType.nativeScaled]: '', + [TokenType.multiCollateral]: + 'A collateral token that can route to multiple routers across chains', }; const TYPE_CHOICES = Object.values(TokenType) @@ -248,6 +250,7 @@ export async function createWarpRouteDeployConfig({ case TokenType.XERC20: case TokenType.XERC20Lockbox: case TokenType.collateralFiat: + case TokenType.multiCollateral: result[chain] = { type, owner, diff --git a/typescript/cli/src/deploy/warp.test.ts b/typescript/cli/src/deploy/warp.test.ts new file mode 100644 index 00000000000..57f7c2a547b --- /dev/null +++ b/typescript/cli/src/deploy/warp.test.ts @@ -0,0 +1,387 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { + ProtocolType, + addressToBytes32, + rootLogger, +} from '@hyperlane-xyz/utils'; +import { + TokenStandard, + TokenType, + type WarpCoreConfig, +} from '@hyperlane-xyz/sdk'; + +import { runWarpRouteCombine } from './warp.js'; + +const DOMAIN_BY_CHAIN: Record = { + anvil2: 31337, + anvil3: 31338, + anvil4: 31339, +}; + +function buildMultiCollateralToken({ + chainName, + symbol, + address, + decimals, + scale, +}: { + chainName: string; + symbol: string; + address: string; + decimals: number; + scale?: number | { numerator: number; denominator: number }; +}) { + return { + chainName, + standard: TokenStandard.EvmHypMultiCollateral, + decimals, + symbol, + name: symbol, + addressOrDenom: address, + collateralAddressOrDenom: address, + ...(scale ? { scale } : {}), + }; +} + +function buildContext( + routes: Record, +) { + const getWarpRoute = sinon.stub(); + const getWarpDeployConfig = sinon.stub(); + + for (const [id, route] of Object.entries(routes)) { + getWarpRoute.withArgs(id).resolves(route.coreConfig); + getWarpDeployConfig.withArgs(id).resolves(route.deployConfig); + } + + const addWarpRouteConfig = sinon.stub().resolves(); + const addWarpRoute = sinon.stub().resolves(); + + return { + context: { + registry: { + getWarpRoute, + getWarpDeployConfig, + addWarpRouteConfig, + addWarpRoute, + }, + multiProvider: { + getDomainId(chain: string) { + return DOMAIN_BY_CHAIN[chain]; + }, + getProtocol() { + return ProtocolType.Ethereum; + }, + }, + } as any, + addWarpRouteConfig, + addWarpRoute, + }; +} + +describe('runWarpRouteCombine', () => { + const ROUTER_A = '0x1111111111111111111111111111111111111111'; + const ROUTER_B = '0x2222222222222222222222222222222222222222'; + const ROUTER_C = '0x3333333333333333333333333333333333333333'; + + afterEach(() => { + sinon.restore(); + }); + + it('warns when combine will remove previously enrolled routers', async () => { + const routeA = { + coreConfig: { + tokens: [ + buildMultiCollateralToken({ + chainName: 'anvil2', + symbol: 'USDC', + address: ROUTER_A, + decimals: 18, + }), + ], + } as WarpCoreConfig, + deployConfig: { + anvil2: { + type: TokenType.multiCollateral, + owner: ROUTER_A, + token: ROUTER_A, + enrolledRouters: { + [DOMAIN_BY_CHAIN.anvil3.toString()]: [addressToBytes32(ROUTER_C)], + }, + }, + }, + }; + const routeB = { + coreConfig: { + tokens: [ + buildMultiCollateralToken({ + chainName: 'anvil3', + symbol: 'USDT', + address: ROUTER_B, + decimals: 18, + }), + ], + } as WarpCoreConfig, + deployConfig: { + anvil3: { + type: TokenType.multiCollateral, + owner: ROUTER_B, + token: ROUTER_B, + }, + }, + }; + + const { context, addWarpRouteConfig } = buildContext({ + 'route-a': routeA, + 'route-b': routeB, + }); + const warnSpy = sinon.spy(rootLogger, 'warn'); + + await runWarpRouteCombine({ + context, + routeIds: ['route-a', 'route-b'], + outputWarpRouteId: 'MULTI/test', + }); + + expect(warnSpy.called).to.equal(true); + const warnings = warnSpy.getCalls().map((call) => String(call.args[0])); + expect( + warnings.some( + (warning) => + warning.includes('route-a') && + warning.includes('will remove 1 enrolled router'), + ), + ).to.equal(true); + + const updatedRouteAConfig = addWarpRouteConfig.getCall(0).args[0]; + expect(updatedRouteAConfig.anvil2.enrolledRouters).to.deep.equal({ + [DOMAIN_BY_CHAIN.anvil3.toString()]: [addressToBytes32(ROUTER_B)], + }); + }); + + it('rejects duplicate route IDs', async () => { + let thrown: Error | undefined; + try { + await runWarpRouteCombine({ + context: {} as any, + routeIds: ['route-a', 'route-a'], + outputWarpRouteId: 'MULTI/test', + }); + } catch (error) { + thrown = error as Error; + } + + expect(thrown?.message).to.include('Duplicate route IDs are not allowed'); + }); + + it('rejects empty route IDs', async () => { + let thrown: Error | undefined; + try { + await runWarpRouteCombine({ + context: {} as any, + routeIds: ['route-a', ''], + outputWarpRouteId: 'MULTI/test', + }); + } catch (error) { + thrown = error as Error; + } + + expect(thrown?.message).to.include('Route IDs must be non-empty strings'); + }); + + it('rejects routes that are not MultiCollateral', async () => { + const routeA = { + coreConfig: { + tokens: [ + buildMultiCollateralToken({ + chainName: 'anvil2', + symbol: 'USDC', + address: ROUTER_A, + decimals: 18, + }), + ], + } as WarpCoreConfig, + deployConfig: { + anvil2: { + type: TokenType.multiCollateral, + owner: ROUTER_A, + token: ROUTER_A, + }, + }, + }; + const routeB = { + coreConfig: { + tokens: [ + { + chainName: 'anvil3', + standard: TokenStandard.EvmHypCollateral, + decimals: 18, + symbol: 'USDT', + name: 'USDT', + addressOrDenom: ROUTER_B, + collateralAddressOrDenom: ROUTER_B, + }, + ], + } as WarpCoreConfig, + deployConfig: { + anvil3: { + type: TokenType.collateral, + owner: ROUTER_B, + token: ROUTER_B, + }, + }, + }; + + const { context } = buildContext({ + 'route-a': routeA, + 'route-b': routeB, + }); + + let thrown: Error | undefined; + try { + await runWarpRouteCombine({ + context, + routeIds: ['route-a', 'route-b'], + outputWarpRouteId: 'MULTI/test', + }); + } catch (error) { + thrown = error as Error; + } + + expect(thrown?.message).to.include( + 'contains non-MultiCollateral deploy configs', + ); + }); + + it('rejects routes with incompatible decimals/scale on the same chain', async () => { + const routeA = { + coreConfig: { + tokens: [ + buildMultiCollateralToken({ + chainName: 'anvil2', + symbol: 'USDC', + address: ROUTER_A, + decimals: 6, + scale: 1_000_000_000_000, + }), + ], + } as WarpCoreConfig, + deployConfig: { + anvil2: { + type: TokenType.multiCollateral, + owner: ROUTER_A, + token: ROUTER_A, + scale: 1_000_000_000_000, + }, + }, + }; + const routeB = { + coreConfig: { + tokens: [ + buildMultiCollateralToken({ + chainName: 'anvil2', + symbol: 'USDT', + address: ROUTER_B, + decimals: 18, + scale: 2, + }), + ], + } as WarpCoreConfig, + deployConfig: { + anvil2: { + type: TokenType.multiCollateral, + owner: ROUTER_B, + token: ROUTER_B, + scale: 2, + }, + }, + }; + + const { context } = buildContext({ + 'route-a': routeA, + 'route-b': routeB, + }); + + let thrown: Error | undefined; + try { + await runWarpRouteCombine({ + context, + routeIds: ['route-a', 'route-b'], + outputWarpRouteId: 'MULTI/test', + }); + } catch (error) { + thrown = error as Error; + } + + expect(thrown?.message).to.include( + 'Incompatible decimals/scale on chain "anvil2"', + ); + }); + + it('formats ratio scales in incompatibility error messages', async () => { + const routeA = { + coreConfig: { + tokens: [ + buildMultiCollateralToken({ + chainName: 'anvil2', + symbol: 'USDC', + address: ROUTER_A, + decimals: 18, + scale: { numerator: 3, denominator: 2 }, + }), + ], + } as WarpCoreConfig, + deployConfig: { + anvil2: { + type: TokenType.multiCollateral, + owner: ROUTER_A, + token: ROUTER_A, + scale: { numerator: 3, denominator: 2 }, + }, + }, + }; + const routeB = { + coreConfig: { + tokens: [ + buildMultiCollateralToken({ + chainName: 'anvil2', + symbol: 'USDT', + address: ROUTER_B, + decimals: 18, + scale: 1, + }), + ], + } as WarpCoreConfig, + deployConfig: { + anvil2: { + type: TokenType.multiCollateral, + owner: ROUTER_B, + token: ROUTER_B, + scale: 1, + }, + }, + }; + + const { context } = buildContext({ + 'route-a': routeA, + 'route-b': routeB, + }); + + let thrown: Error | undefined; + try { + await runWarpRouteCombine({ + context, + routeIds: ['route-a', 'route-b'], + outputWarpRouteId: 'MULTI/test', + }); + } catch (error) { + thrown = error as Error; + } + + expect(thrown?.message).to.include('scale=3/2'); + expect(thrown?.message).to.include('scale=1'); + expect(thrown?.message).to.not.include('[object Object]'); + }); +}); diff --git a/typescript/cli/src/deploy/warp.ts b/typescript/cli/src/deploy/warp.ts index 391f70feb9a..8cbfcd9b63d 100644 --- a/typescript/cli/src/deploy/warp.ts +++ b/typescript/cli/src/deploy/warp.ts @@ -27,6 +27,7 @@ import { type MultisigIsmConfig, type OpStackIsmConfig, type PausableIsmConfig, + type HypTokenRouterConfig, type RoutingIsmConfig, type SubmissionStrategy, type TokenMetadataMap, @@ -36,8 +37,10 @@ import { type TypedAnnotatedTransaction, type WarpCoreConfig, WarpCoreConfigSchema, + type WarpRouteDeployConfig, type WarpRouteDeployConfigMailboxRequired, WarpRouteDeployConfigSchema, + TokenStandard, altVmChainLookup, enrollCrossChainRouters, executeWarpDeploy, @@ -47,12 +50,15 @@ import { getSubmitterBuilder, getTokenConnectionId, isCollateralTokenConfig, + isMultiCollateralTokenConfig, isXERC20TokenConfig, + normalizeScale, splitWarpCoreAndExtendedConfigs, tokenTypeToStandard, } from '@hyperlane-xyz/sdk'; import { type Address, + addressToBytes32, assert, mapAllSettled, mustGet, @@ -266,6 +272,10 @@ export async function runWarpRouteDeploy({ } await writeDeploymentArtifacts(warpCoreConfig, context, warpRouteIdOptions); + await context.registry.addWarpRouteConfig( + warpDeployConfig, + warpRouteIdOptions, + ); await completeDeploy( context, @@ -327,7 +337,6 @@ async function writeDeploymentArtifacts( ) { log('Writing deployment artifacts...'); await context.registry.addWarpRoute(warpCoreConfig, addWarpRouteOptions); - log(indentYamlOrJson(yamlStringify(warpCoreConfig, null, 2), 4)); } @@ -385,8 +394,10 @@ function generateTokenConfigs( for (const chainName of Object.keys(contracts)) { const config = warpDeployConfig[chainName]; const collateralAddressOrDenom = - isCollateralTokenConfig(config) || isXERC20TokenConfig(config) - ? config.token // gets set in the above deriveTokenMetadata() + isCollateralTokenConfig(config) || + isXERC20TokenConfig(config) || + isMultiCollateralTokenConfig(config) + ? (config as { token: string }).token // gets set in the above deriveTokenMetadata() : undefined; const protocol = multiProvider.getProtocol(chainName); @@ -698,14 +709,17 @@ export async function extendWarpRoute( }; } + const warpRouteOptions = params.warpRouteId + ? { warpRouteId: params.warpRouteId } + : addWarpRouteOptions; + // Write the updated artifacts await writeDeploymentArtifacts( updatedWarpCoreConfig, context, - params.warpRouteId - ? { warpRouteId: params.warpRouteId } - : addWarpRouteOptions, + warpRouteOptions, ); + await context.registry.addWarpRouteConfig(warpDeployConfig, warpRouteOptions); // Throw after persisting successes so user can re-run for failures if (allRejected.size > 0) { @@ -1237,3 +1251,225 @@ export async function getSubmitterByStrategy({ config: submissionStrategy, }; } + +type CombineRouteConfig = { + id: string; + coreConfig: WarpCoreConfig; + deployConfig: WarpRouteDeployConfig; +}; + +type CanonicalWholeTokenRatio = { + numerator: bigint; + denominator: bigint; +}; + +function formatScaleForLogs( + scale: WarpCoreConfig['tokens'][number]['scale'], +): string { + if (!scale) return '1'; + const normalizedScale = normalizeScale(scale); + if (normalizedScale.denominator === 1n) { + return normalizedScale.numerator.toString(); + } + return `${normalizedScale.numerator}/${normalizedScale.denominator}`; +} + +function getCanonicalWholeTokenRatio( + token: WarpCoreConfig['tokens'][number], +): CanonicalWholeTokenRatio { + const normalizedScale = normalizeScale(token.scale); + const oneTokenBaseUnits = 10n ** BigInt(token.decimals); + return { + numerator: oneTokenBaseUnits * normalizedScale.numerator, + denominator: normalizedScale.denominator, + }; +} + +function assertCombineRoutesAreValid(routes: CombineRouteConfig[]): void { + for (const route of routes) { + const invalidDeployChains = Object.entries(route.deployConfig) + .filter(([, chainConfig]) => !isMultiCollateralTokenConfig(chainConfig)) + .map(([chain]) => chain); + assert( + invalidDeployChains.length === 0, + `Route "${route.id}" contains non-MultiCollateral deploy configs for chain(s): ${invalidDeployChains.join(', ')}`, + ); + + const invalidCoreTokens = route.coreConfig.tokens.filter( + (token) => token.standard !== TokenStandard.EvmHypMultiCollateral, + ); + assert( + invalidCoreTokens.length === 0, + `Route "${route.id}" contains non-MultiCollateral warp config token(s): ${invalidCoreTokens + .map((token) => `${token.chainName}:${token.addressOrDenom}`) + .join(', ')}`, + ); + } + + const tokensByChain = new Map< + string, + Array<{ routeId: string; token: WarpCoreConfig['tokens'][number] }> + >(); + for (const route of routes) { + for (const token of route.coreConfig.tokens) { + const chainTokens = tokensByChain.get(token.chainName) ?? []; + chainTokens.push({ routeId: route.id, token }); + tokensByChain.set(token.chainName, chainTokens); + } + } + + for (const [chainName, chainTokens] of tokensByChain.entries()) { + if (chainTokens.length <= 1) continue; + + const [base, ...rest] = chainTokens; + const baseRatio = getCanonicalWholeTokenRatio(base.token); + + for (const candidate of rest) { + const candidateRatio = getCanonicalWholeTokenRatio(candidate.token); + const isCompatible = + baseRatio.numerator * candidateRatio.denominator === + candidateRatio.numerator * baseRatio.denominator; + + assert( + isCompatible, + `Incompatible decimals/scale on chain "${chainName}" between route "${base.routeId}" (${base.token.symbol}, decimals=${base.token.decimals}, scale=${formatScaleForLogs(base.token.scale)}) and route "${candidate.routeId}" (${candidate.token.symbol}, decimals=${candidate.token.decimals}, scale=${formatScaleForLogs(candidate.token.scale)}).`, + ); + } + } +} + +/** + * Combines multiple warp routes into a single merged WarpCoreConfig and updates + * each route's deploy config with cross-route enrolledRouters. + */ +export async function runWarpRouteCombine({ + context, + routeIds, + outputWarpRouteId, +}: { + context: WriteCommandContext; + routeIds: string[]; + outputWarpRouteId: string; +}): Promise { + assert(routeIds.length >= 2, 'At least 2 route IDs are required to combine'); + assert( + routeIds.every((id) => id.length > 0), + 'Route IDs must be non-empty strings', + ); + assert( + new Set(routeIds).size === routeIds.length, + 'Duplicate route IDs are not allowed', + ); + + // 1. Read each route's WarpCoreConfig and deploy config + const routes: CombineRouteConfig[] = []; + + for (const id of routeIds) { + const coreConfig = await context.registry.getWarpRoute(id); + assert(coreConfig, `Warp route "${id}" not found in registry`); + const deployConfigRaw = await context.registry.getWarpDeployConfig(id); + const deployConfig = WarpRouteDeployConfigSchema.parse(deployConfigRaw); + routes.push({ + id, + coreConfig, + deployConfig, + }); + } + + assertCombineRoutesAreValid(routes); + + // 2. For each route, update enrolledRouters with routers from other routes + for (const route of routes) { + for (const [chain, chainConfig] of Object.entries( + route.deployConfig, + ) as Array<[string, HypTokenRouterConfig]>) { + if (!isMultiCollateralTokenConfig(chainConfig)) continue; + + const enrolledRouters: Record> = {}; + + // Look at all OTHER routes + for (const otherRoute of routes) { + if (otherRoute.id === route.id) continue; + + // For each token in the other route, add its router to this route's enrolledRouters + for (const otherToken of otherRoute.coreConfig.tokens) { + const otherDomain = context.multiProvider + .getDomainId(otherToken.chainName) + .toString(); + assert( + otherToken.addressOrDenom, + `MultiCollateral token missing addressOrDenom on ${otherToken.chainName}`, + ); + const otherRouter = addressToBytes32(otherToken.addressOrDenom); + + enrolledRouters[otherDomain] ??= new Set(); + enrolledRouters[otherDomain].add(otherRouter); + } + } + + const reconciledEnrolledRouters = Object.fromEntries( + Object.entries(enrolledRouters).map(([domain, routers]) => [ + domain, + [...routers], + ]), + ); + + const routersRemovedByCombine = Object.entries( + chainConfig.enrolledRouters ?? {}, + ).reduce((acc, [domain, routers]) => { + const enrolledAfterCombine = new Set( + reconciledEnrolledRouters[domain] ?? [], + ); + return ( + acc + + routers.filter((router) => !enrolledAfterCombine.has(router)).length + ); + }, 0); + + if (routersRemovedByCombine > 0) { + warnYellow( + `Combining route "${route.id}" on chain "${chain}" will remove ${routersRemovedByCombine} enrolled router(s) not present in --routes. They will be unenrolled on next "warp apply".`, + ); + } + + chainConfig.enrolledRouters = + Object.keys(reconciledEnrolledRouters).length > 0 + ? reconciledEnrolledRouters + : undefined; + } + + // Write updated deploy config back + await context.registry.addWarpRouteConfig(route.deployConfig, { + warpRouteId: route.id, + }); + log(`Updated deploy config for route "${route.id}"`); + } + + // 3. Create merged WarpCoreConfig with all tokens + const mergedConfig: WarpCoreConfig = { tokens: [] }; + const seenTokens = new Set(); + + for (const route of routes) { + for (const token of route.coreConfig.tokens) { + const key = `${token.chainName}:${token.addressOrDenom}`; + assert( + !seenTokens.has(key), + `Duplicate token ${key} across input routes`, + ); + seenTokens.add(key); + mergedConfig.tokens.push({ ...token, connections: [] }); + } + } + + // Full mesh connections (every token → every other token) + fullyConnectTokens(mergedConfig, context.multiProvider); + + // 4. Write merged WarpCoreConfig + const mergedId = outputWarpRouteId; + await context.registry.addWarpRoute(mergedConfig, { warpRouteId: mergedId }); + + logGreen(`✅ Combined ${routes.length} routes into "${mergedId}"`); + log( + `Run "warp apply" for each route to apply on-chain enrollment:\n${routes.map((r) => ` hyperlane warp apply --warp-route-id ${r.id}`).join('\n')}`, + ); +} diff --git a/typescript/cli/src/send/transfer.ts b/typescript/cli/src/send/transfer.ts index a8fecefec9c..d2a6fd81e91 100644 --- a/typescript/cli/src/send/transfer.ts +++ b/typescript/cli/src/send/transfer.ts @@ -14,6 +14,7 @@ import { } from '@hyperlane-xyz/sdk'; import { ProtocolType, + assert, parseWarpRouteMessage, timeout, } from '@hyperlane-xyz/utils'; @@ -40,6 +41,8 @@ export async function sendTestTransfer({ skipWaitForDelivery, selfRelay, skipValidation, + sourceToken, + destinationToken, }: { context: WriteCommandContext; warpCoreConfig: WarpCoreConfig; @@ -50,6 +53,8 @@ export async function sendTestTransfer({ skipWaitForDelivery: boolean; selfRelay?: boolean; skipValidation?: boolean; + sourceToken?: string; + destinationToken?: string; }) { const { multiProvider } = context; @@ -90,6 +95,8 @@ export async function sendTestTransfer({ skipWaitForDelivery, selfRelay, skipValidation, + sourceToken, + destinationToken, }), timeoutSec * 1000, 'Timed out waiting for messages to be delivered', @@ -108,6 +115,8 @@ async function executeDelivery({ skipWaitForDelivery, selfRelay, skipValidation, + sourceToken: sourceTokenAddr, + destinationToken: destTokenAddr, }: { context: WriteCommandContext; origin: ChainName; @@ -118,6 +127,8 @@ async function executeDelivery({ skipWaitForDelivery: boolean; selfRelay?: boolean; skipValidation?: boolean; + sourceToken?: string; + destinationToken?: string; }) { const { multiProvider, registry } = context; @@ -140,7 +151,11 @@ async function executeDelivery({ let token: Token; const tokensForRoute = warpCore.getTokensForRoute(origin, destination); - if (tokensForRoute.length === 0) { + if (sourceTokenAddr) { + const found = warpCore.findToken(origin, sourceTokenAddr); + assert(found, `Source token ${sourceTokenAddr} not found on ${origin}`); + token = found; + } else if (tokensForRoute.length === 0) { logRed(`No Warp Routes found from ${origin} to ${destination}`); throw new Error('Error finding warp route'); } else if (tokensForRoute.length === 1) { @@ -151,12 +166,23 @@ async function executeDelivery({ token = warpCore.findToken(origin, routerAddress)!; } + let destToken: Token | undefined; + if (destTokenAddr) { + const found = warpCore.findToken(destination, destTokenAddr); + assert( + found, + `Destination token ${destTokenAddr} not found on ${destination}`, + ); + destToken = found; + } + if (!skipValidation) { const errors = await warpCore.validateTransfer({ originTokenAmount: token.amount(amount), destination, recipient, sender: signerAddress, + destinationToken: destToken, }); if (errors) { logRed('Error validating transfer', JSON.stringify(errors)); @@ -170,6 +196,7 @@ async function executeDelivery({ destination, sender: signerAddress, recipient, + destinationToken: destToken ?? undefined, }); const txReceipts = []; @@ -180,22 +207,29 @@ async function executeDelivery({ txReceipts.push(txReceipt); } } + assert( + txReceipts.length > 0, + `No supported transfer receipt produced for ${origin} -> ${destination}`, + ); const transferTxReceipt = txReceipts[txReceipts.length - 1]; - const messageIndex: number = 0; - const message: DispatchedMessage = - HyperlaneCore.getDispatchedMessages(transferTxReceipt)[messageIndex]; - - const parsed = parseWarpRouteMessage(message.parsed.body); + const messages = HyperlaneCore.getDispatchedMessages(transferTxReceipt); + const message: DispatchedMessage | undefined = messages[0]; logBlue( `Sent transfer from sender (${signerAddress}) on ${origin} to recipient (${recipient}) on ${destination}.`, ); - logBlue(`Message ID: ${message.id}`); - logBlue(`Explorer Link: ${EXPLORER_URL}/message/${message.id}`); - log(`Message:\n${indentYamlOrJson(yamlStringify(message, null, 2), 4)}`); - log(`Body:\n${indentYamlOrJson(yamlStringify(parsed, null, 2), 4)}`); - if (selfRelay) { + if (message) { + const parsed = parseWarpRouteMessage(message.parsed.body); + logBlue(`Message ID: ${message.id}`); + logBlue(`Explorer Link: ${EXPLORER_URL}/message/${message.id}`); + log(`Message:\n${indentYamlOrJson(yamlStringify(message, null, 2), 4)}`); + log(`Body:\n${indentYamlOrJson(yamlStringify(parsed, null, 2), 4)}`); + } else { + logGreen('Same-chain transfer completed (no interchain message).'); + } + + if (selfRelay && message) { return runSelfRelay({ txReceipt: transferTxReceipt, multiProvider: multiProvider, @@ -204,7 +238,7 @@ async function executeDelivery({ }); } - if (skipWaitForDelivery) return; + if (skipWaitForDelivery || !message) return; // Max wait 10 minutes await core.waitForMessageProcessed(transferTxReceipt, 10000, 60); diff --git a/typescript/cli/src/tests/commands/helpers.ts b/typescript/cli/src/tests/commands/helpers.ts index 124f0d56401..6662865d0fb 100644 --- a/typescript/cli/src/tests/commands/helpers.ts +++ b/typescript/cli/src/tests/commands/helpers.ts @@ -47,7 +47,7 @@ export const SELECT_NATIVE_TOKEN_TYPE = { check: (currentOutput: string) => !!currentOutput.match(/Select .+?'s token type/), // Scroll up through the token type list and select native - input: `${KeyBoardKeys.ARROW_UP.repeat(5)}${KeyBoardKeys.ENTER}`, + input: `${KeyBoardKeys.ARROW_UP.repeat(6)}${KeyBoardKeys.ENTER}`, }; /** diff --git a/typescript/cli/src/tests/ethereum/commands/warp.ts b/typescript/cli/src/tests/ethereum/commands/warp.ts index 48ea86d1712..6f90b9265f2 100644 --- a/typescript/cli/src/tests/ethereum/commands/warp.ts +++ b/typescript/cli/src/tests/ethereum/commands/warp.ts @@ -249,6 +249,9 @@ export function hyperlaneWarpSendRelay({ value = 2, chains, roundTrip, + sourceToken, + destinationToken, + skipValidation, }: { origin?: string; destination?: string; @@ -257,6 +260,9 @@ export function hyperlaneWarpSendRelay({ value?: number | string; chains?: string[]; roundTrip?: boolean; + sourceToken?: string; + destinationToken?: string; + skipValidation?: boolean; }): ProcessPromise { return $`${localTestRunCmdPrefix()} hyperlane warp send \ ${relay ? '--relay' : []} \ @@ -269,7 +275,26 @@ export function hyperlaneWarpSendRelay({ --yes \ --amount ${value} \ ${chains?.length ? chains.flatMap((c) => ['--chains', c]) : []} \ - ${roundTrip ? ['--round-trip'] : []} `; + ${roundTrip ? ['--round-trip'] : []} \ + ${sourceToken ? ['--source-token', sourceToken] : []} \ + ${destinationToken ? ['--destination-token', destinationToken] : []} \ + ${skipValidation ? ['--skip-validation'] : []} `; +} + +export function hyperlaneWarpCombine({ + routes, + outputWarpRouteId, +}: { + routes: string; + outputWarpRouteId: string; +}): ProcessPromise { + return $`${localTestRunCmdPrefix()} hyperlane warp combine \ + --registry ${REGISTRY_PATH} \ + --routes ${routes} \ + ${outputWarpRouteId ? ['--output-warp-route-id', outputWarpRouteId] : []} \ + --key ${ANVIL_KEY} \ + --verbosity debug \ + --yes`; } export function hyperlaneWarpRebalancer( @@ -448,6 +473,8 @@ export function generateWarpConfigs( // No adapter has been implemented yet TokenType.ethEverclear, TokenType.collateralEverclear, + // Collateral-only, can't pair with synthetics; tested separately + TokenType.multiCollateral, // Forward-compatibility placeholder, not deployable TokenType.unknown, ]); diff --git a/typescript/cli/src/tests/ethereum/warp/warp-multi-collateral.e2e-test.ts b/typescript/cli/src/tests/ethereum/warp/warp-multi-collateral.e2e-test.ts new file mode 100644 index 00000000000..901e2c49442 --- /dev/null +++ b/typescript/cli/src/tests/ethereum/warp/warp-multi-collateral.e2e-test.ts @@ -0,0 +1,287 @@ +import { expect } from 'chai'; +import { parseUnits } from 'ethers/lib/utils.js'; + +import { + type ChainAddresses, + createWarpRouteConfigId, +} from '@hyperlane-xyz/registry'; +import { + type ChainMap, + type Token, + TokenType, + type WarpCoreConfig, + type WarpRouteDeployConfig, +} from '@hyperlane-xyz/sdk'; + +import { readYamlOrJson, writeYamlOrJson } from '../../../utils/files.js'; +import { deployOrUseExistingCore } from '../commands/core.js'; +import { deployToken } from '../commands/helpers.js'; +import { + hyperlaneWarpApplyRaw, + hyperlaneWarpCombine, + hyperlaneWarpDeploy, + hyperlaneWarpSendRelay, +} from '../commands/warp.js'; +import { + ANVIL_DEPLOYER_ADDRESS, + ANVIL_KEY, + CHAIN_NAME_2, + CHAIN_NAME_3, + CORE_CONFIG_PATH, + REGISTRY_PATH, + WARP_DEPLOY_OUTPUT_PATH, + getCombinedWarpRoutePath, +} from '../consts.js'; + +describe('hyperlane warp multiCollateral CLI e2e tests', async function () { + this.timeout(300_000); + + let chain2Addresses: ChainAddresses = {}; + let chain3Addresses: ChainAddresses = {}; + + const ownerAddress = ANVIL_DEPLOYER_ADDRESS; + + before(async function () { + [chain2Addresses, chain3Addresses] = await Promise.all([ + deployOrUseExistingCore(CHAIN_NAME_2, CORE_CONFIG_PATH, ANVIL_KEY), + deployOrUseExistingCore(CHAIN_NAME_3, CORE_CONFIG_PATH, ANVIL_KEY), + ]); + }); + + it('should send cross-stablecoin transfer via CLI warp send (USDC -> USDT cross-chain)', async function () { + // Deploy USDC(6dec) + USDT(18dec) on both chains + const usdcChain2 = await deployToken( + ANVIL_KEY, + CHAIN_NAME_2, + 6, + 'CUSDC', + 'CLI USDC', + ); + const usdtChain2 = await deployToken( + ANVIL_KEY, + CHAIN_NAME_2, + 18, + 'CUSDT', + 'CLI USDT', + ); + const usdcChain3 = await deployToken( + ANVIL_KEY, + CHAIN_NAME_3, + 6, + 'CUSDC', + 'CLI USDC', + ); + const usdtChain3 = await deployToken( + ANVIL_KEY, + CHAIN_NAME_3, + 18, + 'CUSDT', + 'CLI USDT', + ); + + // Deploy USDC route via CLI + const usdcSymbol = await usdcChain2.symbol(); + const usdcScale = 1e12; // 6→18 dec + const usdcWarpId = createWarpRouteConfigId( + usdcSymbol, + `${CHAIN_NAME_2}-${CHAIN_NAME_3}`, + ); + writeYamlOrJson(WARP_DEPLOY_OUTPUT_PATH, { + [CHAIN_NAME_2]: { + type: TokenType.multiCollateral, + token: usdcChain2.address, + scale: usdcScale, + mailbox: chain2Addresses.mailbox, + owner: ownerAddress, + }, + [CHAIN_NAME_3]: { + type: TokenType.multiCollateral, + token: usdcChain3.address, + scale: usdcScale, + mailbox: chain3Addresses.mailbox, + owner: ownerAddress, + }, + } as WarpRouteDeployConfig); + await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, usdcWarpId); + + // Deploy USDT route via CLI + const usdtSymbol = await usdtChain2.symbol(); + const usdtWarpId = createWarpRouteConfigId( + usdtSymbol, + `${CHAIN_NAME_2}-${CHAIN_NAME_3}`, + ); + writeYamlOrJson(WARP_DEPLOY_OUTPUT_PATH, { + [CHAIN_NAME_2]: { + type: TokenType.multiCollateral, + token: usdtChain2.address, + mailbox: chain2Addresses.mailbox, + owner: ownerAddress, + }, + [CHAIN_NAME_3]: { + type: TokenType.multiCollateral, + token: usdtChain3.address, + mailbox: chain3Addresses.mailbox, + owner: ownerAddress, + }, + } as WarpRouteDeployConfig); + await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, usdtWarpId); + + // Use warp combine to cross-enroll and create merged config + const mergedWarpRouteId = 'MULTI/test-mc'; + await hyperlaneWarpCombine({ + routes: `${usdcWarpId},${usdtWarpId}`, + outputWarpRouteId: mergedWarpRouteId, + }); + + // Apply enrollment on-chain for each route + await hyperlaneWarpApplyRaw({ warpRouteId: usdcWarpId }); + await hyperlaneWarpApplyRaw({ warpRouteId: usdtWarpId }); + + // Read deployed configs to get router addresses + const USDC_WARP_CONFIG_PATH = getCombinedWarpRoutePath(usdcSymbol, [ + CHAIN_NAME_2, + CHAIN_NAME_3, + ]); + const USDT_WARP_CONFIG_PATH = getCombinedWarpRoutePath(usdtSymbol, [ + CHAIN_NAME_2, + CHAIN_NAME_3, + ]); + + const usdcTokens: ChainMap = ( + readYamlOrJson(USDC_WARP_CONFIG_PATH) as WarpCoreConfig + ).tokens.reduce((acc, curr) => ({ ...acc, [curr.chainName]: curr }), {}); + const usdtTokens: ChainMap = ( + readYamlOrJson(USDT_WARP_CONFIG_PATH) as WarpCoreConfig + ).tokens.reduce((acc, curr) => ({ ...acc, [curr.chainName]: curr }), {}); + + const usdcRouter2Addr = usdcTokens[CHAIN_NAME_2].addressOrDenom; + const usdtRouter3Addr = usdtTokens[CHAIN_NAME_3].addressOrDenom; + + // Collateralize USDT router on chain 3 + const usdtCollateral = parseUnits('10', 18); + await (await usdtChain3.transfer(usdtRouter3Addr, usdtCollateral)).wait(); + + // Read the merged WarpCoreConfig created by warp combine + const MERGED_CONFIG_PATH = `${REGISTRY_PATH}/deployments/warp_routes/${mergedWarpRouteId}-config.yaml`; + + // Send cross-stablecoin transfer via CLI: USDC(chain2) -> USDT(chain3) + const sendAmount = parseUnits('1', 6); // 1 USDC in 6-dec + const balanceBefore = await usdtChain3.balanceOf(ownerAddress); + await hyperlaneWarpSendRelay({ + origin: CHAIN_NAME_2, + destination: CHAIN_NAME_3, + warpCorePath: MERGED_CONFIG_PATH, + sourceToken: usdcRouter2Addr, + destinationToken: usdtRouter3Addr, + value: sendAmount.toString(), + skipValidation: true, + }); + + // Verify: 1 USDC(6dec) → canonical 1e18 → 1 USDT(18dec) = 1e18 + const balanceAfter = await usdtChain3.balanceOf(ownerAddress); + const received = balanceAfter.sub(balanceBefore); + expect(received.toString()).to.equal(parseUnits('1', 18).toString()); + }); + + it('should swap same-chain via CLI warp send (USDC -> USDT local)', async function () { + // Deploy USDC(6dec) and USDT(18dec) on chain 2 + const usdc = await deployToken( + ANVIL_KEY, + CHAIN_NAME_2, + 6, + 'LUSDC2', + 'Local USDC 2', + ); + const usdt = await deployToken( + ANVIL_KEY, + CHAIN_NAME_2, + 18, + 'LUSDT2', + 'Local USDT 2', + ); + + // Deploy USDC route via CLI (single-chain) + const usdcSymbol = await usdc.symbol(); + const usdcScale = 1e12; // 6→18 dec + const usdcWarpId = createWarpRouteConfigId(usdcSymbol, CHAIN_NAME_2); + writeYamlOrJson(WARP_DEPLOY_OUTPUT_PATH, { + [CHAIN_NAME_2]: { + type: TokenType.multiCollateral, + token: usdc.address, + scale: usdcScale, + mailbox: chain2Addresses.mailbox, + owner: ownerAddress, + }, + } as WarpRouteDeployConfig); + await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, usdcWarpId); + + // Deploy USDT route via CLI (single-chain) + const usdtSymbol = await usdt.symbol(); + const usdtWarpId = createWarpRouteConfigId(usdtSymbol, CHAIN_NAME_2); + writeYamlOrJson(WARP_DEPLOY_OUTPUT_PATH, { + [CHAIN_NAME_2]: { + type: TokenType.multiCollateral, + token: usdt.address, + mailbox: chain2Addresses.mailbox, + owner: ownerAddress, + }, + } as WarpRouteDeployConfig); + await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, usdtWarpId); + + // Combine routes (cross-enroll same-chain routers) + const mergedWarpRouteId = 'MULTI/test-mc-local'; + await hyperlaneWarpCombine({ + routes: `${usdcWarpId},${usdtWarpId}`, + outputWarpRouteId: mergedWarpRouteId, + }); + + // Apply enrollment on-chain for each route + await hyperlaneWarpApplyRaw({ warpRouteId: usdcWarpId }); + await hyperlaneWarpApplyRaw({ warpRouteId: usdtWarpId }); + + // Read deployed configs to get router addresses + const USDC_WARP_CONFIG_PATH = getCombinedWarpRoutePath(usdcSymbol, [ + CHAIN_NAME_2, + ]); + const USDT_WARP_CONFIG_PATH = getCombinedWarpRoutePath(usdtSymbol, [ + CHAIN_NAME_2, + ]); + + const usdcTokens: ChainMap = ( + readYamlOrJson(USDC_WARP_CONFIG_PATH) as WarpCoreConfig + ).tokens.reduce((acc, curr) => ({ ...acc, [curr.chainName]: curr }), {}); + const usdtTokens: ChainMap = ( + readYamlOrJson(USDT_WARP_CONFIG_PATH) as WarpCoreConfig + ).tokens.reduce((acc, curr) => ({ ...acc, [curr.chainName]: curr }), {}); + + const usdcRouter2Addr = usdcTokens[CHAIN_NAME_2].addressOrDenom; + const usdtRouter2Addr = usdtTokens[CHAIN_NAME_2].addressOrDenom; + + // Collateralize USDT router + const usdtCollateral = parseUnits('10', 18); + await (await usdt.transfer(usdtRouter2Addr, usdtCollateral)).wait(); + + // Read the merged WarpCoreConfig created by warp combine + const MERGED_CONFIG_PATH = `${REGISTRY_PATH}/deployments/warp_routes/${mergedWarpRouteId}-config.yaml`; + + // Send same-chain swap via CLI: USDC -> USDT on chain 2 + const swapAmount = parseUnits('1', 6); // 1 USDC + const balanceBefore = await usdt.balanceOf(ownerAddress); + + await hyperlaneWarpSendRelay({ + origin: CHAIN_NAME_2, + destination: CHAIN_NAME_2, + warpCorePath: MERGED_CONFIG_PATH, + sourceToken: usdcRouter2Addr, + destinationToken: usdtRouter2Addr, + value: swapAmount.toString(), + relay: false, // Same-chain: handle() called directly, no relay needed + skipValidation: true, + }); + + // Verify: 1 USDC(6dec) → canonical 1e18 → 1 USDT(18dec) = 1e18 + const balanceAfter = await usdt.balanceOf(ownerAddress); + const received = balanceAfter.sub(balanceBefore); + expect(received.toString()).to.equal(parseUnits('1', 18).toString()); + }); +}); diff --git a/typescript/rebalancer-sim/package.json b/typescript/rebalancer-sim/package.json index 431139b1385..b8f8434c321 100644 --- a/typescript/rebalancer-sim/package.json +++ b/typescript/rebalancer-sim/package.json @@ -14,7 +14,8 @@ "repository": "https://github.com/hyperlane-xyz/hyperlane-monorepo", "files": [ "dist", - "src" + "src", + "scenarios" ], "type": "module", "main": "./dist/index.js", diff --git a/typescript/rebalancer-sim/src/ResultsExporter.ts b/typescript/rebalancer-sim/src/ResultsExporter.ts new file mode 100644 index 00000000000..870109b85a5 --- /dev/null +++ b/typescript/rebalancer-sim/src/ResultsExporter.ts @@ -0,0 +1,138 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { ethers } from 'ethers'; +import { assert } from '@hyperlane-xyz/utils'; + +import type { ScenarioFile, SimulationResult } from './types.js'; +import { generateTimelineHtml } from './visualizer/HtmlTimelineGenerator.js'; + +export type SimulationComparison = { + bestCompletionRate: string; + bestLatency: string; +}; + +export type SaveSimulationResultsOptions = { + outputDir: string; + scenarioName: string; + scenarioFile: ScenarioFile; + results: SimulationResult[]; + comparison?: SimulationComparison; +}; + +export type SaveSimulationResultsOutput = { + jsonPath: string; + htmlPath: string; +}; + +export function saveSimulationResults( + options: SaveSimulationResultsOptions, +): SaveSimulationResultsOutput { + const { outputDir, scenarioName, scenarioFile, results, comparison } = + options; + + assert( + !scenarioName.includes('..') && !path.isAbsolute(scenarioName), + `Invalid scenario name: ${scenarioName}`, + ); + + fs.mkdirSync(outputDir, { recursive: true }); + + const output: any = { + scenario: scenarioName, + timestamp: new Date().toISOString(), + description: scenarioFile.description, + expectedBehavior: scenarioFile.expectedBehavior, + expectations: scenarioFile.expectations, + results: results.map((result) => ({ + rebalancerName: result.rebalancerName, + kpis: { + totalTransfers: result.kpis.totalTransfers, + completedTransfers: result.kpis.completedTransfers, + completionRate: result.kpis.completionRate, + averageLatency: result.kpis.averageLatency, + p50Latency: result.kpis.p50Latency, + p95Latency: result.kpis.p95Latency, + p99Latency: result.kpis.p99Latency, + totalRebalances: result.kpis.totalRebalances, + rebalanceVolume: result.kpis.rebalanceVolume.toString(), + }, + })), + config: { + timing: scenarioFile.defaultTiming, + initialCollateral: scenarioFile.defaultInitialCollateral, + initialImbalance: scenarioFile.initialImbalance, + }, + }; + + if (comparison) { + output.comparison = comparison; + } + + const jsonPath = path.join(outputDir, `${scenarioName}.json`); + fs.writeFileSync(jsonPath, JSON.stringify(output, null, 2)); + + const firstOrigin = Object.keys(scenarioFile.defaultBridgeConfig)[0]; + const firstDest = firstOrigin + ? Object.keys(scenarioFile.defaultBridgeConfig[firstOrigin])[0] + : undefined; + const bridgeDelay = + firstOrigin && firstDest + ? scenarioFile.defaultBridgeConfig[firstOrigin][firstDest].deliveryDelay + : 0; + + const vizConfig: Record = { + scenarioName: scenarioFile.name, + description: scenarioFile.description, + expectedBehavior: scenarioFile.expectedBehavior, + transferCount: scenarioFile.transfers.length, + duration: scenarioFile.duration, + bridgeDeliveryDelay: bridgeDelay, + rebalancerPollingFrequency: + scenarioFile.defaultTiming.rebalancerPollingFrequency, + userTransferDelay: scenarioFile.defaultTiming.userTransferDeliveryDelay, + }; + + if (scenarioFile.defaultStrategyConfig.type === 'weighted') { + vizConfig.targetWeights = {}; + vizConfig.tolerances = {}; + for (const [chain, chainConfig] of Object.entries( + scenarioFile.defaultStrategyConfig.chains, + )) { + if (chainConfig.weighted) { + vizConfig.targetWeights[chain] = Math.round( + parseFloat(chainConfig.weighted.weight) * 100, + ); + vizConfig.tolerances[chain] = Math.round( + parseFloat(chainConfig.weighted.tolerance) * 100, + ); + } + } + } + + vizConfig.initialCollateral = {}; + for (const chain of scenarioFile.chains) { + const base = parseFloat( + ethers.utils.formatEther(scenarioFile.defaultInitialCollateral), + ); + const extra = scenarioFile.initialImbalance?.[chain] + ? parseFloat( + ethers.utils.formatEther(scenarioFile.initialImbalance[chain]), + ) + : 0; + vizConfig.initialCollateral[chain] = (base + extra).toString(); + } + + const html = generateTimelineHtml( + results, + { title: `${scenarioFile.name}: ${scenarioFile.description}` }, + vizConfig, + ); + const htmlPath = path.join(outputDir, `${scenarioName}.html`); + fs.writeFileSync(htmlPath, html); + + return { + jsonPath, + htmlPath, + }; +} diff --git a/typescript/rebalancer-sim/src/ScenarioLoader.ts b/typescript/rebalancer-sim/src/ScenarioLoader.ts index 23f2551eb0c..f352617d0fe 100644 --- a/typescript/rebalancer-sim/src/ScenarioLoader.ts +++ b/typescript/rebalancer-sim/src/ScenarioLoader.ts @@ -2,22 +2,42 @@ import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; +import { assert } from '@hyperlane-xyz/utils'; import type { Address } from '@hyperlane-xyz/utils'; import type { ScenarioFile, TransferScenario } from './types.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const SCENARIOS_DIR = path.join(__dirname, '..', 'scenarios'); +const DEFAULT_SCENARIOS_DIR = path.join(__dirname, '..', 'scenarios'); +const SCENARIOS_DIR_ENV = 'SCENARIOS_DIR'; + +function resolveScenariosDir(): string { + const envValue = process.env[SCENARIOS_DIR_ENV]?.trim(); + if (!envValue) { + return DEFAULT_SCENARIOS_DIR; + } + + if (path.isAbsolute(envValue)) { + return envValue; + } + + return path.resolve(process.cwd(), envValue); +} /** * Load a scenario file (full format with metadata and defaults) */ export function loadScenarioFile(name: string): ScenarioFile { - const filePath = path.join(SCENARIOS_DIR, `${name}.json`); + assert( + !name.includes('..') && !path.isAbsolute(name), + `Invalid scenario name: ${name}`, + ); + const scenariosDir = resolveScenariosDir(); + const filePath = path.join(scenariosDir, `${name}.json`); if (!fs.existsSync(filePath)) { throw new Error( - `Scenario not found: ${name}. Run 'pnpm generate-scenarios' first.`, + `Scenario not found: ${name} in ${scenariosDir}. Run 'pnpm generate-scenarios' first.`, ); } @@ -55,19 +75,21 @@ function deserializeTransfers(file: ScenarioFile): TransferScenario { * List all available scenarios */ export function listScenarios(): string[] { - if (!fs.existsSync(SCENARIOS_DIR)) { + const scenariosDir = resolveScenariosDir(); + if (!fs.existsSync(scenariosDir)) { return []; } return fs - .readdirSync(SCENARIOS_DIR) + .readdirSync(scenariosDir) .filter((f) => f.endsWith('.json')) - .map((f) => f.replace('.json', '')); + .map((f) => f.replace('.json', '')) + .sort(); } /** * Get the scenarios directory path */ export function getScenariosDir(): string { - return SCENARIOS_DIR; + return resolveScenariosDir(); } diff --git a/typescript/rebalancer-sim/src/index.ts b/typescript/rebalancer-sim/src/index.ts index ada8749a5c1..90ffbc309de 100644 --- a/typescript/rebalancer-sim/src/index.ts +++ b/typescript/rebalancer-sim/src/index.ts @@ -23,6 +23,12 @@ export { loadScenario, loadScenarioFile, } from './ScenarioLoader.js'; +export { + saveSimulationResults, + type SaveSimulationResultsOptions, + type SaveSimulationResultsOutput, + type SimulationComparison, +} from './ResultsExporter.js'; // Rebalancer runners export { NoOpRebalancer } from './runners/NoOpRebalancer.js'; diff --git a/typescript/rebalancer-sim/test/scenarios/scenario-loader.test.ts b/typescript/rebalancer-sim/test/scenarios/scenario-loader.test.ts new file mode 100644 index 00000000000..9340e80df52 --- /dev/null +++ b/typescript/rebalancer-sim/test/scenarios/scenario-loader.test.ts @@ -0,0 +1,110 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { expect } from 'chai'; + +import { + getScenariosDir, + listScenarios, + loadScenario, + loadScenarioFile, +} from '../../src/ScenarioLoader.js'; + +describe('ScenarioLoader', () => { + const originalScenariosDir = process.env['SCENARIOS_DIR']; + let customDir: string | undefined; + + afterEach(() => { + if (customDir) { + fs.rmSync(customDir, { recursive: true, force: true }); + customDir = undefined; + } + if (originalScenariosDir === undefined) { + delete process.env['SCENARIOS_DIR']; + } else { + process.env['SCENARIOS_DIR'] = originalScenariosDir; + } + }); + + it('uses bundled package scenarios by default', () => { + delete process.env['SCENARIOS_DIR']; + + const scenariosDir = getScenariosDir(); + expect(fs.existsSync(scenariosDir)).to.equal(true); + + const scenarioNames = listScenarios(); + expect(scenarioNames.length).to.be.greaterThan(0); + + const file = loadScenarioFile(scenarioNames[0]); + expect(file.name).to.equal(scenarioNames[0]); + expect(file.transfers.length).to.be.greaterThan(0); + }); + + it('supports SCENARIOS_DIR override', () => { + customDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'rebalancer-sim-scenarios-'), + ); + + const customScenario = { + name: 'custom-one', + description: 'Custom scenario', + expectedBehavior: 'Loads from override dir', + duration: 1000, + chains: ['chain1', 'chain2'], + transfers: [ + { + id: 't1', + timestamp: 0, + origin: 'chain1', + destination: 'chain2', + amount: '1', + user: '0x0000000000000000000000000000000000000001', + }, + ], + defaultInitialCollateral: '100', + defaultTiming: { + userTransferDeliveryDelay: 100, + rebalancerPollingFrequency: 500, + userTransferInterval: 100, + }, + defaultBridgeConfig: { + chain1: { + chain2: { deliveryDelay: 100, failureRate: 0, deliveryJitter: 0 }, + }, + chain2: { + chain1: { deliveryDelay: 100, failureRate: 0, deliveryJitter: 0 }, + }, + }, + defaultStrategyConfig: { + type: 'minAmount' as const, + chains: { + chain1: { + minAmount: { min: '1', target: '2' }, + bridgeLockTime: 1000, + }, + chain2: { + minAmount: { min: '1', target: '2' }, + bridgeLockTime: 1000, + }, + }, + }, + expectations: { + minCompletionRate: 1, + }, + }; + + fs.writeFileSync( + path.join(customDir, 'custom-one.json'), + JSON.stringify(customScenario, null, 2), + ); + process.env['SCENARIOS_DIR'] = customDir; + + expect(getScenariosDir()).to.equal(customDir); + expect(listScenarios()).to.deep.equal(['custom-one']); + + const loaded = loadScenario('custom-one'); + expect(loaded.name).to.equal('custom-one'); + expect(loaded.transfers[0].amount).to.equal(BigInt(1)); + }); +}); diff --git a/typescript/rebalancer-sim/test/utils/simulation-helpers.ts b/typescript/rebalancer-sim/test/utils/simulation-helpers.ts index 7569613bd3a..a7962e7a9cb 100644 --- a/typescript/rebalancer-sim/test/utils/simulation-helpers.ts +++ b/typescript/rebalancer-sim/test/utils/simulation-helpers.ts @@ -11,10 +11,10 @@ import { cleanupProductionRebalancer, cleanupSimpleRunner, deployMultiDomainSimulation, - generateTimelineHtml, getWarpTokenBalance, loadScenario, loadScenarioFile, + saveSimulationResults, } from '../../src/index.js'; import type { ChainStrategyConfig, @@ -315,96 +315,12 @@ export function saveResults( ): void { ensureResultsDir(); - const output: any = { - scenario: scenarioName, - timestamp: new Date().toISOString(), - description: file.description, - expectedBehavior: file.expectedBehavior, - expectations: file.expectations, - results: results.map((r) => ({ - rebalancerName: r.rebalancerName, - kpis: { - totalTransfers: r.kpis.totalTransfers, - completedTransfers: r.kpis.completedTransfers, - completionRate: r.kpis.completionRate, - averageLatency: r.kpis.averageLatency, - p50Latency: r.kpis.p50Latency, - p95Latency: r.kpis.p95Latency, - p99Latency: r.kpis.p99Latency, - totalRebalances: r.kpis.totalRebalances, - rebalanceVolume: r.kpis.rebalanceVolume.toString(), - }, - })), - config: { - timing: file.defaultTiming, - initialCollateral: file.defaultInitialCollateral, - initialImbalance: file.initialImbalance, - }, - }; - - if (comparison) { - output.comparison = comparison; - } - - // Save JSON results - const jsonPath = path.join(RESULTS_DIR, `${scenarioName}.json`); - fs.writeFileSync(jsonPath, JSON.stringify(output, null, 2)); - - // Generate HTML timeline visualization - const firstOrigin = Object.keys(file.defaultBridgeConfig)[0]; - const firstDest = firstOrigin - ? Object.keys(file.defaultBridgeConfig[firstOrigin])[0] - : undefined; - const bridgeDelay = - firstOrigin && firstDest - ? file.defaultBridgeConfig[firstOrigin][firstDest].deliveryDelay - : 0; - - const vizConfig: Record = { - scenarioName: file.name, - description: file.description, - expectedBehavior: file.expectedBehavior, - transferCount: file.transfers.length, - duration: file.duration, - bridgeDeliveryDelay: bridgeDelay, - rebalancerPollingFrequency: file.defaultTiming.rebalancerPollingFrequency, - userTransferDelay: file.defaultTiming.userTransferDeliveryDelay, - }; - - if (file.defaultStrategyConfig.type === 'weighted') { - vizConfig.targetWeights = {}; - vizConfig.tolerances = {}; - for (const [chain, chainConfig] of Object.entries( - file.defaultStrategyConfig.chains, - )) { - if (chainConfig.weighted) { - vizConfig.targetWeights[chain] = Math.round( - parseFloat(chainConfig.weighted.weight) * 100, - ); - vizConfig.tolerances[chain] = Math.round( - parseFloat(chainConfig.weighted.tolerance) * 100, - ); - } - } - } - - vizConfig.initialCollateral = {}; - for (const chain of file.chains) { - const base = parseFloat( - ethers.utils.formatEther(file.defaultInitialCollateral), - ); - const extra = file.initialImbalance?.[chain] - ? parseFloat(ethers.utils.formatEther(file.initialImbalance[chain])) - : 0; - vizConfig.initialCollateral[chain] = (base + extra).toString(); - } - - const html = generateTimelineHtml( + const { htmlPath } = saveSimulationResults({ + outputDir: RESULTS_DIR, + scenarioName, + scenarioFile: file, results, - { title: `${file.name}: ${file.description}` }, - vizConfig, - ); - const htmlPath = path.join(RESULTS_DIR, `${scenarioName}.html`); - fs.writeFileSync(htmlPath, html); + comparison, + }); console.log(` Timeline saved to: ${htmlPath}`); } diff --git a/typescript/sdk/package.json b/typescript/sdk/package.json index 066cf65b9ec..081db9dd462 100644 --- a/typescript/sdk/package.json +++ b/typescript/sdk/package.json @@ -49,6 +49,7 @@ "@hyperlane-xyz/core": "workspace:*", "@hyperlane-xyz/cosmos-sdk": "workspace:*", "@hyperlane-xyz/deploy-sdk": "workspace:*", + "@hyperlane-xyz/multicollateral": "workspace:*", "@hyperlane-xyz/provider-sdk": "workspace:*", "@hyperlane-xyz/radix-sdk": "workspace:*", "@hyperlane-xyz/starknet-core": "workspace:*", diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index 6da1679ff4d..5e5d9821f05 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -792,6 +792,8 @@ export { EverclearCollateralTokenConfig, EverclearEthBridgeTokenConfig, isXERC20TokenConfig, + isMultiCollateralTokenConfig, + MultiCollateralTokenConfig, NativeTokenConfig, NativeTokenConfigSchema, SyntheticRebaseTokenConfig, diff --git a/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts b/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts index 0f6aab80737..4578743096c 100644 --- a/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts +++ b/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts @@ -23,6 +23,7 @@ import { MockEverclearAdapter__factory, MovableCollateralRouter__factory, } from '@hyperlane-xyz/core'; +import { MultiCollateral__factory } from '@hyperlane-xyz/multicollateral'; import { EvmIsmModule, HookConfig, @@ -166,7 +167,10 @@ describe('EvmWarpModule', async () => { }); const movableCollateralTypes = Object.values(TokenType).filter( - isMovableCollateralTokenType, + (t) => + isMovableCollateralTokenType(t) && + // MultiCollateral contract too large for hardhat; covered by forge tests + t !== TokenType.multiCollateral, ) as MovableTokenType[]; const everclearTokenBridgeTypes = [ @@ -213,6 +217,12 @@ describe('EvmWarpModule', async () => { type: TokenType.nativeScaled, allowedRebalancers, }, + [TokenType.multiCollateral]: { + ...baseConfig, + type: TokenType.multiCollateral, + token: token.address, + allowedRebalancers, + }, }; }; @@ -886,6 +896,75 @@ describe('EvmWarpModule', async () => { ); }); + it('normalizes chain-name enrolledRouters keys for multicollateral enroll/unenroll txs', async () => { + const destinationDomain = multiProvider.getDomainId(TestChainName.test2); + const keepRouterAddress = '0x1111111111111111111111111111111111111111'; + const keepRouter = addressToBytes32(keepRouterAddress); + const addRouterAddress = '0x2222222222222222222222222222222222222222'; + const addRouter = addressToBytes32(addRouterAddress); + const removeRouterAddress = '0x3333333333333333333333333333333333333333'; + const removeRouter = addressToBytes32(removeRouterAddress); + + const module = new EvmWarpModule(multiProvider, { + chain, + config: { + ...baseConfig, + type: TokenType.multiCollateral, + token: token.address, + } as HypTokenRouterConfig, + addresses: { + deployedTokenRoute: randomAddress(), + }, + } as ConstructorParameters[1]); + + const actualConfig = { + ...baseConfig, + type: TokenType.multiCollateral, + token: token.address, + enrolledRouters: { + [destinationDomain]: [keepRouter, removeRouter], + }, + } as Parameters< + EvmWarpModule['createEnrollMultiCollateralRoutersTxs'] + >[0]; + const expectedConfig = { + ...baseConfig, + type: TokenType.multiCollateral, + token: token.address, + enrolledRouters: { + [TestChainName.test2]: [keepRouterAddress.toUpperCase(), addRouter], + }, + } as HypTokenRouterConfig; + + const enrollTxs = module.createEnrollMultiCollateralRoutersTxs( + actualConfig, + expectedConfig, + ); + expect(enrollTxs.length).to.equal(1); + const [enrollDomains, enrollRouters] = + MultiCollateral__factory.createInterface().decodeFunctionData( + 'enrollRouters', + enrollTxs[0].data!, + ); + expect(enrollDomains.map(Number)).to.deep.equal([destinationDomain]); + expect(enrollRouters[0].toLowerCase()).to.equal(addRouter.toLowerCase()); + + const unenrollTxs = module.createUnenrollMultiCollateralRoutersTxs( + actualConfig, + expectedConfig, + ); + expect(unenrollTxs.length).to.equal(1); + const [unenrollDomains, unenrollRouters] = + MultiCollateral__factory.createInterface().decodeFunctionData( + 'unenrollRouters', + unenrollTxs[0].data!, + ); + expect(unenrollDomains.map(Number)).to.deep.equal([destinationDomain]); + expect(unenrollRouters[0].toLowerCase()).to.equal( + removeRouter.toLowerCase(), + ); + }); + it('should update the owner only if they are different', async () => { const config = { ...baseConfig, diff --git a/typescript/sdk/src/token/EvmWarpModule.ts b/typescript/sdk/src/token/EvmWarpModule.ts index fe4ba4f9e21..4959f476d2c 100644 --- a/typescript/sdk/src/token/EvmWarpModule.ts +++ b/typescript/sdk/src/token/EvmWarpModule.ts @@ -13,6 +13,7 @@ import { TokenRouter__factory, } from '@hyperlane-xyz/core'; import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js'; +import { MultiCollateral__factory } from '@hyperlane-xyz/multicollateral'; import { Address, Domain, @@ -24,6 +25,7 @@ import { deepEquals, difference, eqAddress, + isAddressEvm, isNullish, isObjEmpty, isZeroishAddress, @@ -79,6 +81,7 @@ import { derivedIsmAddress, isEverclearTokenBridgeConfig, isMovableCollateralTokenConfig, + isMultiCollateralTokenConfig, isXERC20TokenConfig, } from './types.js'; @@ -211,6 +214,14 @@ export class EvmWarpModule extends HyperlaneModule< ...this.createUpdateEverclearFeeParamsTxs(actualConfig, expectedConfig), ...this.createRemoveEverclearFeeParamsTxs(actualConfig, expectedConfig), + ...this.createEnrollMultiCollateralRoutersTxs( + actualConfig, + expectedConfig, + ), + ...this.createUnenrollMultiCollateralRoutersTxs( + actualConfig, + expectedConfig, + ), ...xerc20Txs, ...this.createOwnershipUpdateTxs(actualConfig, expectedConfig), @@ -412,6 +423,136 @@ export class EvmWarpModule extends HyperlaneModule< })); } + /** + * Create transactions to enroll MultiCollateral routers. + */ + createEnrollMultiCollateralRoutersTxs( + actualConfig: DerivedTokenRouterConfig, + expectedConfig: HypTokenRouterConfig, + ): AnnotatedEV5Transaction[] { + if ( + !isMultiCollateralTokenConfig(expectedConfig) || + !isMultiCollateralTokenConfig(actualConfig) + ) { + return []; + } + + if (!expectedConfig.enrolledRouters) { + return []; + } + + const actualEnrolled = resolveRouterMapConfig( + this.multiProvider, + actualConfig.enrolledRouters ?? {}, + ); + const expectedEnrolled = resolveRouterMapConfig( + this.multiProvider, + expectedConfig.enrolledRouters, + ); + + const domainsToEnroll: number[] = []; + const routersToEnroll: string[] = []; + + for (const [domain, expectedRouters] of Object.entries(expectedEnrolled)) { + const domainId = Number(domain); + const actualRouters = new Set( + (actualEnrolled[domainId] ?? []).map((router) => + this.toCanonicalRouterId(router), + ), + ); + for (const router of expectedRouters) { + const canonicalRouter = this.toCanonicalRouterId(router); + if (!actualRouters.has(canonicalRouter)) { + domainsToEnroll.push(domainId); + routersToEnroll.push(canonicalRouter); + } + } + } + + if (domainsToEnroll.length === 0) { + return []; + } + + return [ + { + chainId: this.chainId, + annotation: `Enrolling ${domainsToEnroll.length} MultiCollateral routers on ${this.args.addresses.deployedTokenRoute} on ${this.chainName}`, + to: this.args.addresses.deployedTokenRoute, + data: MultiCollateral__factory.createInterface().encodeFunctionData( + 'enrollRouters', + [domainsToEnroll, routersToEnroll], + ), + }, + ]; + } + + /** + * Create transactions to unenroll MultiCollateral routers. + */ + createUnenrollMultiCollateralRoutersTxs( + actualConfig: DerivedTokenRouterConfig, + expectedConfig: HypTokenRouterConfig, + ): AnnotatedEV5Transaction[] { + if ( + !isMultiCollateralTokenConfig(expectedConfig) || + !isMultiCollateralTokenConfig(actualConfig) + ) { + return []; + } + + const actualEnrolled = resolveRouterMapConfig( + this.multiProvider, + actualConfig.enrolledRouters ?? {}, + ); + const expectedEnrolled = resolveRouterMapConfig( + this.multiProvider, + expectedConfig.enrolledRouters ?? {}, + ); + + const domainsToUnenroll: number[] = []; + const routersToUnenroll: string[] = []; + + for (const [domain, actualRouters] of Object.entries(actualEnrolled)) { + const domainId = Number(domain); + const expectedRouters = new Set( + (expectedEnrolled[domainId] ?? []).map((router) => + this.toCanonicalRouterId(router), + ), + ); + for (const router of actualRouters) { + const canonicalRouter = this.toCanonicalRouterId(router); + if (!expectedRouters.has(canonicalRouter)) { + domainsToUnenroll.push(domainId); + routersToUnenroll.push(canonicalRouter); + } + } + } + + if (domainsToUnenroll.length === 0) { + return []; + } + + return [ + { + chainId: this.chainId, + annotation: `Unenrolling ${domainsToUnenroll.length} MultiCollateral routers on ${this.args.addresses.deployedTokenRoute} on ${this.chainName}`, + to: this.args.addresses.deployedTokenRoute, + data: MultiCollateral__factory.createInterface().encodeFunctionData( + 'unenrollRouters', + [domainsToUnenroll, routersToUnenroll], + ), + }, + ]; + } + + private toCanonicalRouterId(router: string): string { + const lower = router.toLowerCase(); + if (isAddressEvm(lower)) { + return addressToBytes32(lower); + } + return lower; + } + async getAllowedBridgesApprovalTxs( actualConfig: DerivedTokenRouterConfig, expectedConfig: HypTokenRouterConfig, @@ -1371,6 +1512,21 @@ export class EvmWarpModule extends HyperlaneModule< } } + if ( + isMultiCollateralTokenConfig(config) && + config.enrolledRouters && + Object.keys(config.enrolledRouters).length > 0 + ) { + const enrollTxs = warpModule.createEnrollMultiCollateralRoutersTxs( + actualConfig, + config, + ); + + for (const tx of enrollTxs) { + await multiProvider.sendTransaction(chain, tx); + } + } + return warpModule; } } diff --git a/typescript/sdk/src/token/EvmWarpRouteReader.hardhat-test.ts b/typescript/sdk/src/token/EvmWarpRouteReader.hardhat-test.ts index 9c20b7aa76b..159726c14e1 100644 --- a/typescript/sdk/src/token/EvmWarpRouteReader.hardhat-test.ts +++ b/typescript/sdk/src/token/EvmWarpRouteReader.hardhat-test.ts @@ -30,6 +30,7 @@ import { XERC20Test__factory, } from '@hyperlane-xyz/core'; import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js'; +import { MultiCollateral__factory } from '@hyperlane-xyz/multicollateral'; import { ContractVerifier, ExplorerLicenseType, @@ -1007,6 +1008,73 @@ describe('EvmWarpRouteReader', async () => { fetchScaleStub.restore(); }); + it('derives multicollateral config with scale from the router', async () => { + const routerAddress = '0x1000000000000000000000000000000000000001'; + const wrappedTokenAddress = '0x2000000000000000000000000000000000000002'; + const localDomain = 31337; + const remoteDomain = 31338; + const localRouter = addressToBytes32( + '0x3000000000000000000000000000000000000003', + ); + const remoteRouter = addressToBytes32( + '0x4000000000000000000000000000000000000004', + ); + const expectedScale = { + numerator: 1n, + denominator: 1_000_000_000_000n, + }; + + const mcConnectStub = sinon + .stub(MultiCollateral__factory, 'connect') + .returns({ + wrappedToken: sinon.stub().resolves(wrappedTokenAddress), + localDomain: sinon.stub().resolves(localDomain), + getEnrolledRouters: sinon + .stub() + .callsFake(async (domain: number) => + domain === localDomain ? [localRouter] : [remoteRouter], + ), + } as any); + const tokenRouterConnectStub = sinon + .stub(TokenRouter__factory, 'connect') + .returns({ + domains: sinon.stub().resolves([remoteDomain]), + } as any); + const metadataStub = sinon + .stub(evmERC20WarpRouteReader, 'fetchERC20Metadata') + .resolves({ + name: TOKEN_NAME, + symbol: TOKEN_NAME, + decimals: TOKEN_DECIMALS, + isNft: false, + }); + const scaleStub = sinon + .stub(evmERC20WarpRouteReader, 'fetchScale') + .resolves(expectedScale); + + const deriveMultiCollateralTokenConfig = (evmERC20WarpRouteReader as any) + .deriveMultiCollateralTokenConfig as (address: string) => Promise; + try { + const derivedConfig = await deriveMultiCollateralTokenConfig.call( + evmERC20WarpRouteReader, + routerAddress, + ); + + expect(derivedConfig.type).to.equal(TokenType.multiCollateral); + expect(derivedConfig.token).to.equal(wrappedTokenAddress); + expect(derivedConfig.scale).to.deep.equal(expectedScale); + expect(derivedConfig.enrolledRouters).to.deep.equal({ + [localDomain.toString()]: [localRouter], + [remoteDomain.toString()]: [remoteRouter], + }); + } finally { + mcConnectStub.restore(); + tokenRouterConnectStub.restore(); + metadataStub.restore(); + scaleStub.restore(); + } + }); + describe('Backward compatibility for token type detection', () => { // Test table for token type detection const tokenTypeTestCases = [ diff --git a/typescript/sdk/src/token/EvmWarpRouteReader.ts b/typescript/sdk/src/token/EvmWarpRouteReader.ts index e5118e23fd3..18281871366 100644 --- a/typescript/sdk/src/token/EvmWarpRouteReader.ts +++ b/typescript/sdk/src/token/EvmWarpRouteReader.ts @@ -27,6 +27,7 @@ import { TokenRouter__factory, } from '@hyperlane-xyz/core'; import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js'; +import { MultiCollateral__factory } from '@hyperlane-xyz/multicollateral'; import { Address, arrayToObject, @@ -144,6 +145,8 @@ export class EvmWarpRouteReader extends EvmRouterReader { this.deriveEverclearEthTokenBridgeConfig.bind(this), [TokenType.collateralEverclear]: this.deriveEverclearCollateralTokenBridgeConfig.bind(this), + [TokenType.multiCollateral]: + this.deriveMultiCollateralTokenConfig.bind(this), }; this.contractVerifier = @@ -524,6 +527,21 @@ export class EvmWarpRouteReader extends EvmRouterReader { error, ); } + + try { + const mc = MultiCollateral__factory.connect( + warpRouteAddress, + this.provider, + ); + // getEnrolledRouters(uint32) is unique to MultiCollateral + await mc.getEnrolledRouters(0); + return TokenType.multiCollateral; + } catch (error) { + this.logger.debug( + `Warp route token at address "${warpRouteAddress}" on chain "${this.chain}" is not a ${TokenType.multiCollateral}`, + error, + ); + } } return tokenType as TokenType; @@ -1112,6 +1130,53 @@ export class EvmWarpRouteReader extends EvmRouterReader { }; } + /** + * Derives the configuration for a MultiCollateral router. + */ + private async deriveMultiCollateralTokenConfig( + hypTokenAddress: Address, + ): Promise { + const mc = MultiCollateral__factory.connect(hypTokenAddress, this.provider); + const tokenRouter = TokenRouter__factory.connect( + hypTokenAddress, + this.provider, + ); + + const [collateralTokenAddress, remoteDomains, localDomain, scale] = + await Promise.all([ + mc.wrappedToken(), + tokenRouter.domains(), + mc.localDomain(), + this.fetchScale(hypTokenAddress), + ]); + + const erc20TokenMetadata = await this.fetchERC20Metadata( + collateralTokenAddress, + ); + + // Build enrolledRouters: domain → bytes32[] for all domains (remote + local) + const allDomains = [...remoteDomains.map(Number), localDomain]; + const enrolledRouters: Record = {}; + + await Promise.all( + allDomains.map(async (domain) => { + const routers = await mc.getEnrolledRouters(domain); + if (routers.length > 0) { + enrolledRouters[domain.toString()] = [...routers]; + } + }), + ); + + return { + ...erc20TokenMetadata, + type: TokenType.multiCollateral, + token: collateralTokenAddress, + scale, + enrolledRouters: + Object.keys(enrolledRouters).length > 0 ? enrolledRouters : undefined, + }; + } + async fetchERC20Metadata(tokenAddress: Address): Promise { const erc20 = HypERC20__factory.connect(tokenAddress, this.provider); const [name, symbol, decimals] = await Promise.all([ diff --git a/typescript/sdk/src/token/IToken.ts b/typescript/sdk/src/token/IToken.ts index 1e7ea04f99a..7cae2734a64 100644 --- a/typescript/sdk/src/token/IToken.ts +++ b/typescript/sdk/src/token/IToken.ts @@ -84,6 +84,7 @@ export interface IToken extends TokenArgs { isHypToken(): boolean; isIbcToken(): boolean; isMultiChainToken(): boolean; + isMultiCollateralToken(): boolean; getConnections(): TokenConnection[]; diff --git a/typescript/sdk/src/token/Token.test.ts b/typescript/sdk/src/token/Token.test.ts index 86d324583c5..88e8e1d6293 100644 --- a/typescript/sdk/src/token/Token.test.ts +++ b/typescript/sdk/src/token/Token.test.ts @@ -135,6 +135,7 @@ const STANDARD_TO_TOKEN: Record = { }, [TokenStandard.EvmHypEverclearCollateral]: null, [TokenStandard.EvmHypEverclearEth]: null, + [TokenStandard.EvmHypMultiCollateral]: null, // Sealevel [TokenStandard.SealevelSpl]: { diff --git a/typescript/sdk/src/token/Token.ts b/typescript/sdk/src/token/Token.ts index 1fbe50476cd..ae744d9f943 100644 --- a/typescript/sdk/src/token/Token.ts +++ b/typescript/sdk/src/token/Token.ts @@ -51,6 +51,7 @@ import { CosmIbcTokenAdapter, CosmNativeTokenAdapter, } from './adapters/CosmosTokenAdapter.js'; +import { EvmHypMultiCollateralAdapter } from './adapters/EvmMultiCollateralAdapter.js'; import { EvmHypCollateralFiatAdapter, EvmHypNativeAdapter, @@ -264,6 +265,10 @@ export class Token implements IToken { return new EvmMovableCollateralAdapter(chainName, multiProvider, { token: addressOrDenom, }); + } else if (standard === TokenStandard.EvmHypMultiCollateral) { + return new EvmHypMultiCollateralAdapter(chainName, multiProvider, { + token: addressOrDenom, + }); } else if (standard === TokenStandard.EvmHypRebaseCollateral) { return new EvmHypRebaseCollateralAdapter(chainName, multiProvider, { token: addressOrDenom, @@ -502,6 +507,10 @@ export class Token implements IToken { return TOKEN_MULTI_CHAIN_STANDARDS.includes(this.standard); } + isMultiCollateralToken(): boolean { + return this.standard === TokenStandard.EvmHypMultiCollateral; + } + getConnections(): TokenConnection[] { return this.connections || []; } diff --git a/typescript/sdk/src/token/TokenStandard.ts b/typescript/sdk/src/token/TokenStandard.ts index 6c7b0489977..c974e8d8bda 100644 --- a/typescript/sdk/src/token/TokenStandard.ts +++ b/typescript/sdk/src/token/TokenStandard.ts @@ -27,6 +27,7 @@ export enum TokenStandard { EvmM0PortalLite = 'EvmM0PortalLite', EvmHypEverclearCollateral = 'EvmHypEverclearCollateral', EvmHypEverclearEth = 'EvmHypEverclearEth', + EvmHypMultiCollateral = 'EvmHypMultiCollateral', // Sealevel (Solana) SealevelSpl = 'SealevelSpl', @@ -95,6 +96,7 @@ export const TOKEN_STANDARD_TO_PROTOCOL: Record< EvmM0PortalLite: ProtocolType.Ethereum, [TokenStandard.EvmHypEverclearCollateral]: ProtocolType.Ethereum, [TokenStandard.EvmHypEverclearEth]: ProtocolType.Ethereum, + [TokenStandard.EvmHypMultiCollateral]: ProtocolType.Ethereum, // Sealevel (Solana) SealevelSpl: ProtocolType.Sealevel, @@ -176,6 +178,7 @@ export const TOKEN_COLLATERALIZED_STANDARDS = [ TokenStandard.RadixHypCollateral, TokenStandard.StarknetHypCollateral, TokenStandard.StarknetHypNative, + TokenStandard.EvmHypMultiCollateral, ]; export const XERC20_STANDARDS = [ @@ -211,6 +214,7 @@ export const TOKEN_HYP_STANDARDS = [ TokenStandard.EvmHypVSXERC20, TokenStandard.EvmHypVSXERC20Lockbox, TokenStandard.EvmM0PortalLite, + TokenStandard.EvmHypMultiCollateral, TokenStandard.SealevelHypNative, TokenStandard.SealevelHypCollateral, TokenStandard.SealevelHypSynthetic, @@ -344,6 +348,7 @@ export const EVM_TOKEN_TYPE_TO_STANDARD: Record< [TokenType.nativeOpL2]: TokenStandard.EvmHypNative, [TokenType.ethEverclear]: TokenStandard.EvmHypEverclearEth, [TokenType.collateralEverclear]: TokenStandard.EvmHypEverclearCollateral, + [TokenType.multiCollateral]: TokenStandard.EvmHypMultiCollateral, }; // Cosmos Native supported token types diff --git a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts new file mode 100644 index 00000000000..30eafd29ccb --- /dev/null +++ b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts @@ -0,0 +1,169 @@ +import { expect } from 'chai'; +import { BigNumber } from 'ethers'; +import sinon from 'sinon'; + +import { test1 } from '../../consts/testChains.js'; +import { MultiProtocolProvider } from '../../providers/MultiProtocolProvider.js'; + +import { EvmHypMultiCollateralAdapter } from './EvmMultiCollateralAdapter.js'; + +describe('EvmHypMultiCollateralAdapter', () => { + const ROUTER_ADDRESS = '0x1111111111111111111111111111111111111111'; + const COLLATERAL_ADDRESS = '0x2222222222222222222222222222222222222222'; + const TARGET_ROUTER = '0x3333333333333333333333333333333333333333'; + const RECIPIENT = '0x4444444444444444444444444444444444444444'; + const DESTINATION_DOMAIN = 31337; + + let adapter: EvmHypMultiCollateralAdapter; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + const multiProvider = + MultiProtocolProvider.createTestMultiProtocolProvider(); + adapter = new EvmHypMultiCollateralAdapter(test1.name, multiProvider, { + token: ROUTER_ADDRESS, + collateralToken: COLLATERAL_ADDRESS, + }); + }); + + afterEach(() => sandbox.restore()); + + it('computes token fee as (quoted token out - input amount) + external fee', async () => { + const quoteTransferRemoteTo = sinon.stub().resolves([ + { amount: BigNumber.from('1000'), token: COLLATERAL_ADDRESS }, + { amount: BigNumber.from('1500'), token: COLLATERAL_ADDRESS }, + { amount: BigNumber.from('25'), token: COLLATERAL_ADDRESS }, + ] as any); + (adapter as any).multiCollateralContract = { quoteTransferRemoteTo }; + + const quote = await adapter.quoteTransferRemoteToGas({ + destination: DESTINATION_DOMAIN, + recipient: RECIPIENT, + amount: 1000n, + targetRouter: TARGET_ROUTER, + }); + + expect(quote.igpQuote.amount).to.equal(1000n); + expect(quote.tokenFeeQuote?.amount).to.equal(525n); + expect(quote.tokenFeeQuote?.addressOrDenom).to.equal(COLLATERAL_ADDRESS); + }); + + it('returns zero token fee when output equals input and external fee is zero', async () => { + const quoteTransferRemoteTo = sinon.stub().resolves([ + { amount: BigNumber.from('7'), token: COLLATERAL_ADDRESS }, + { amount: BigNumber.from('123456'), token: COLLATERAL_ADDRESS }, + { amount: BigNumber.from('0'), token: COLLATERAL_ADDRESS }, + ] as any); + (adapter as any).multiCollateralContract = { quoteTransferRemoteTo }; + + const quote = await adapter.quoteTransferRemoteToGas({ + destination: DESTINATION_DOMAIN, + recipient: RECIPIENT, + amount: 123456n, + targetRouter: TARGET_ROUTER, + }); + + expect(quote.igpQuote.amount).to.equal(7n); + expect(quote.tokenFeeQuote?.amount).to.equal(0n); + expect(quote.tokenFeeQuote?.addressOrDenom).to.equal(COLLATERAL_ADDRESS); + }); + + it('sets igp quote token when gas quote is non-native', async () => { + const GAS_TOKEN = '0x5555555555555555555555555555555555555555'; + const quoteTransferRemoteTo = sinon.stub().resolves([ + { amount: BigNumber.from('777'), token: GAS_TOKEN }, + { amount: BigNumber.from('1500'), token: COLLATERAL_ADDRESS }, + { amount: BigNumber.from('10'), token: COLLATERAL_ADDRESS }, + ] as any); + (adapter as any).multiCollateralContract = { quoteTransferRemoteTo }; + + const quote = await adapter.quoteTransferRemoteToGas({ + destination: DESTINATION_DOMAIN, + recipient: RECIPIENT, + amount: 1000n, + targetRouter: TARGET_ROUTER, + }); + + expect(quote.igpQuote.amount).to.equal(777n); + expect(quote.igpQuote.addressOrDenom).to.equal(GAS_TOKEN); + }); + + it('does not send native value when gas quote token is non-native', async () => { + const GAS_TOKEN = '0x6666666666666666666666666666666666666666'; + const quoteTransferRemoteTo = sinon.stub().resolves([ + { amount: BigNumber.from('50'), token: GAS_TOKEN }, + { amount: BigNumber.from('1500'), token: COLLATERAL_ADDRESS }, + { amount: BigNumber.from('10'), token: COLLATERAL_ADDRESS }, + ] as any); + const transferRemoteTo = sinon.stub().resolves({}); + (adapter as any).multiCollateralContract = { + quoteTransferRemoteTo, + populateTransaction: { transferRemoteTo }, + }; + + await adapter.populateTransferRemoteToTx({ + destination: DESTINATION_DOMAIN, + recipient: RECIPIENT, + amount: 1000n, + targetRouter: TARGET_ROUTER, + }); + + expect(transferRemoteTo.calledOnce).to.equal(true); + const callArgs = transferRemoteTo.getCall(0).args; + expect(callArgs[4].value).to.equal('0'); + }); + + it('sends native value when gas quote token is native', async () => { + const quoteTransferRemoteTo = sinon.stub().resolves([ + { + amount: BigNumber.from('88'), + token: '0x0000000000000000000000000000000000000000', + }, + { amount: BigNumber.from('1500'), token: COLLATERAL_ADDRESS }, + { amount: BigNumber.from('10'), token: COLLATERAL_ADDRESS }, + ] as any); + const transferRemoteTo = sinon.stub().resolves({}); + (adapter as any).multiCollateralContract = { + quoteTransferRemoteTo, + populateTransaction: { transferRemoteTo }, + }; + + await adapter.populateTransferRemoteToTx({ + destination: DESTINATION_DOMAIN, + recipient: RECIPIENT, + amount: 1000n, + targetRouter: TARGET_ROUTER, + }); + + expect(transferRemoteTo.calledOnce).to.equal(true); + const callArgs = transferRemoteTo.getCall(0).args; + expect(callArgs[4].value).to.equal('88'); + }); + + it('throws when quote denominations mismatch', async () => { + const quoteTransferRemoteTo = sinon.stub().resolves([ + { + amount: BigNumber.from('88'), + token: '0x0000000000000000000000000000000000000000', + }, + { amount: BigNumber.from('1500'), token: COLLATERAL_ADDRESS }, + { amount: BigNumber.from('10'), token: TARGET_ROUTER }, + ] as any); + (adapter as any).multiCollateralContract = { quoteTransferRemoteTo }; + + let thrown: Error | undefined; + try { + await adapter.quoteTransferRemoteToGas({ + destination: DESTINATION_DOMAIN, + recipient: RECIPIENT, + amount: 1000n, + targetRouter: TARGET_ROUTER, + }); + } catch (error) { + thrown = error as Error; + } + expect(thrown).to.not.equal(undefined); + expect(thrown!.message).to.contain('mismatched token fee denominations'); + }); +}); diff --git a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts new file mode 100644 index 00000000000..c6ac2fc8f03 --- /dev/null +++ b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts @@ -0,0 +1,135 @@ +import { PopulatedTransaction } from 'ethers'; + +import { + MultiCollateral, + MultiCollateral__factory, +} from '@hyperlane-xyz/multicollateral'; +import { + Address, + Domain, + Numberish, + addressToBytes32, + assert, + isAddressEvm, + isZeroishAddress, +} from '@hyperlane-xyz/utils'; + +import { MultiProtocolProvider } from '../../providers/MultiProtocolProvider.js'; +import { ChainName } from '../../types.js'; + +import { InterchainGasQuote } from './ITokenAdapter.js'; +import { EvmHypCollateralAdapter } from './EvmTokenAdapter.js'; + +/** + * Adapter for MultiCollateral routers. + * Supports transferRemoteTo for both cross-chain and same-chain transfers. + */ +export class EvmHypMultiCollateralAdapter extends EvmHypCollateralAdapter { + public readonly multiCollateralContract: MultiCollateral; + + constructor( + public readonly chainName: ChainName, + public readonly multiProvider: MultiProtocolProvider, + public readonly addresses: { + token: Address; // router address + collateralToken?: Address; // optional hint for callers; resolved onchain + }, + ) { + super(chainName, multiProvider, { token: addresses.token }); + this.multiCollateralContract = MultiCollateral__factory.connect( + addresses.token, + this.getProvider(), + ); + } + + // ============ MultiCollateral-specific methods ============ + + /** + * Populate cross-chain transfer to a specific target router. + */ + private async quoteTransferRemoteToRaw(params: { + destination: Domain; + recipient: Address; + amount: Numberish; + targetRouter: Address; + }) { + const recipientBytes32 = addressToBytes32(params.recipient); + const targetRouterBytes32 = addressToBytes32(params.targetRouter); + + return this.multiCollateralContract.quoteTransferRemoteTo( + params.destination, + recipientBytes32, + params.amount.toString(), + targetRouterBytes32, + ); + } + + async populateTransferRemoteToTx(params: { + destination: Domain; + recipient: Address; + amount: Numberish; + targetRouter: Address; + interchainGas?: InterchainGasQuote; + }): Promise { + const recipientBytes32 = addressToBytes32(params.recipient); + const targetRouterBytes32 = addressToBytes32(params.targetRouter); + const quote = + params.interchainGas ?? (await this.quoteTransferRemoteToGas(params)); + const nativeGas = !quote.igpQuote.addressOrDenom + ? quote.igpQuote.amount.toString() + : '0'; + + return this.multiCollateralContract.populateTransaction.transferRemoteTo( + params.destination, + recipientBytes32, + params.amount.toString(), + targetRouterBytes32, + { value: nativeGas }, + ); + } + + /** + * Quote fees for transferRemoteTo. + */ + async quoteTransferRemoteToGas(params: { + destination: Domain; + recipient: Address; + amount: Numberish; + targetRouter: Address; + }): Promise { + const quotes = await this.quoteTransferRemoteToRaw(params); + assert( + quotes.length >= 3, + 'quoteTransferRemoteTo returned incomplete quote set', + ); + assert( + isZeroishAddress(quotes[1].token) || isAddressEvm(quotes[1].token), + 'quoteTransferRemoteTo returned invalid token fee denomination', + ); + assert( + quotes[2].token.toLowerCase() === quotes[1].token.toLowerCase(), + 'quoteTransferRemoteTo returned mismatched token fee denominations', + ); + + const amount = BigInt(params.amount.toString()); + const tokenQuoteAmount = BigInt(quotes[1].amount.toString()); + const externalFeeAmount = BigInt(quotes[2].amount.toString()); + const tokenFeeAmount = + tokenQuoteAmount >= amount + ? tokenQuoteAmount - amount + externalFeeAmount + : externalFeeAmount; + + return { + igpQuote: { + amount: BigInt(quotes[0].amount.toString()), + addressOrDenom: isZeroishAddress(quotes[0].token) + ? undefined + : quotes[0].token, + }, + tokenFeeQuote: { + addressOrDenom: quotes[1].token, + amount: tokenFeeAmount, + }, + }; + } +} diff --git a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts index 6ccf7739bb4..8c8b4b039b7 100644 --- a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts +++ b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts @@ -78,6 +78,8 @@ import { buildBlockTagOverrides } from './utils.js'; // Computed by estimating on a few different chains, taking the max, and then adding ~50% padding export const EVM_TRANSFER_REMOTE_GAS_ESTIMATE = 450_000n; const TOKEN_FEE_CONTRACT_VERSION = '10.0.0'; +type RawTupleQuote = { 0: string; 1: BigNumber }; +type RawTokenBridgeQuote = { token: string; amount: BigNumber }; // Interacts with native currencies export class EvmNativeTokenAdapter @@ -316,14 +318,12 @@ export class EvmHypSyntheticAdapter assert(recipient, 'Recipient must be defined for quoteTransferRemoteGas'); const recipBytes32 = addressToBytes32(addressToByteHexString(recipient)); - const [igpQuote, ...feeQuotes] = await this.contract.quoteTransferRemote( - destination, - recipBytes32, - amount.toString(), - ); + const [igpQuote, ...feeQuotes] = await this.contract[ + 'quoteTransferRemote(uint32,bytes32,uint256)' + ](destination, recipBytes32, amount.toString()); const [, igpAmount] = igpQuote; - const tokenFeeQuotes: Quote[] = feeQuotes.map((quote) => ({ + const tokenFeeQuotes: Quote[] = feeQuotes.map((quote: RawTupleQuote) => ({ addressOrDenom: quote[0], amount: BigInt(quote[1].toString()), })); @@ -554,13 +554,11 @@ export class EvmMovableCollateralAdapter this.getProvider(), ); - const quotes = await bridgeContract.quoteTransferRemote( - domain, - addressToBytes32(recipient), - amount, - ); + const quotes = await bridgeContract[ + 'quoteTransferRemote(uint32,bytes32,uint256)' + ](domain, addressToBytes32(recipient), amount); - return quotes.map((quote) => ({ + return quotes.map((quote: RawTokenBridgeQuote) => ({ igpQuote: { addressOrDenom: quote.token === ethersConstants.AddressZero ? undefined : quote.token, diff --git a/typescript/sdk/src/token/config.ts b/typescript/sdk/src/token/config.ts index 37d586a74ee..d58f780e30a 100644 --- a/typescript/sdk/src/token/config.ts +++ b/typescript/sdk/src/token/config.ts @@ -17,6 +17,8 @@ export const TokenType = { ethEverclear: 'ethEverclear', // backwards compatible alias to native nativeScaled: 'nativeScaled', + // Multi-router collateral (direct 1-message atomic transfers between collateral routers) + multiCollateral: 'multiCollateral', unknown: 'unknown', } as const; @@ -44,6 +46,7 @@ const isMovableCollateralTokenTypeMap = { [TokenType.syntheticUri]: false, [TokenType.ethEverclear]: false, [TokenType.collateralEverclear]: false, + [TokenType.multiCollateral]: true, // MultiCollateral extends HypERC20Collateral [TokenType.unknown]: false, } as const; diff --git a/typescript/sdk/src/token/contracts.ts b/typescript/sdk/src/token/contracts.ts index ddc00dde08a..bf93f222f55 100644 --- a/typescript/sdk/src/token/contracts.ts +++ b/typescript/sdk/src/token/contracts.ts @@ -21,6 +21,7 @@ import { TokenBridgeCctpV1__factory, TokenBridgeCctpV2__factory, } from '@hyperlane-xyz/core'; +import { MultiCollateral__factory } from '@hyperlane-xyz/multicollateral'; import { DeployableTokenType, TokenType } from './config.js'; @@ -43,6 +44,7 @@ export const hypERC20contracts = { [TokenType.nativeScaled]: 'HypNative', [TokenType.ethEverclear]: 'EverclearEthBridge', [TokenType.collateralEverclear]: 'EverclearTokenBridge', + [TokenType.multiCollateral]: 'MultiCollateral', } as const satisfies Record; export type HypERC20contracts = typeof hypERC20contracts; @@ -70,6 +72,7 @@ export const hypERC20factories = { [TokenType.ethEverclear]: new EverclearEthBridge__factory(), [TokenType.collateralEverclear]: new EverclearTokenBridge__factory(), + [TokenType.multiCollateral]: new MultiCollateral__factory(), } as const satisfies Record; export type HypERC20Factories = typeof hypERC20factories; diff --git a/typescript/sdk/src/token/deploy.ts b/typescript/sdk/src/token/deploy.ts index 31b379d8351..c8e23c3384a 100644 --- a/typescript/sdk/src/token/deploy.ts +++ b/typescript/sdk/src/token/deploy.ts @@ -14,6 +14,7 @@ import { TokenBridgeCctpV2__factory, TokenRouter, } from '@hyperlane-xyz/core'; +import { MultiCollateral__factory } from '@hyperlane-xyz/multicollateral'; import { Address, ProtocolType, @@ -70,6 +71,7 @@ import { isEverclearEthBridgeTokenConfig, isEverclearTokenBridgeConfig, isMovableCollateralTokenConfig, + isMultiCollateralTokenConfig, isNativeTokenConfig, isOpL1TokenConfig, isOpL2TokenConfig, @@ -156,7 +158,11 @@ abstract class TokenDeployer< // TODO: derive as specified in https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/5296 const { numerator, denominator } = normalizeScale(config.scale); - if (isCollateralTokenConfig(config) || isXERC20TokenConfig(config)) { + if ( + isCollateralTokenConfig(config) || + isXERC20TokenConfig(config) || + isMultiCollateralTokenConfig(config) + ) { return [config.token, numerator, denominator, config.mailbox]; } else if (isEverclearCollateralTokenConfig(config)) { return [ @@ -248,7 +254,8 @@ abstract class TokenDeployer< if ( isCollateralTokenConfig(config) || isXERC20TokenConfig(config) || - isNativeTokenConfig(config) + isNativeTokenConfig(config) || + isMultiCollateralTokenConfig(config) ) { return defaultArgs; } else if ( @@ -637,6 +644,57 @@ abstract class TokenDeployer< ); } + protected async enrollRouters( + configMap: ChainMap, + deployedContractsMap: HyperlaneContractsMap, + ): Promise { + await promiseObjAll( + objMap(configMap, async (chain, config) => { + if (!isMultiCollateralTokenConfig(config)) { + return; + } + if ( + !config.enrolledRouters || + Object.keys(config.enrolledRouters).length === 0 + ) { + return; + } + + const router = this.router(deployedContractsMap[chain]).address; + const mc = MultiCollateral__factory.connect( + router, + this.multiProvider.getSigner(chain), + ); + + const resolvedRouters = resolveRouterMapConfig( + this.multiProvider, + config.enrolledRouters, + ); + + const domains: number[] = []; + const routers: string[] = []; + for (const [domainId, routerAddresses] of Object.entries( + resolvedRouters, + )) { + for (const routerAddr of routerAddresses) { + domains.push(Number(domainId)); + routers.push(addressToBytes32(routerAddr)); + } + } + + if (domains.length > 0) { + this.logger.info( + `Batch enrolling ${domains.length} routers for ${chain}`, + ); + await this.multiProvider.handleTx( + chain, + mc.enrollRouters(domains, routers), + ); + } + }), + ); + } + async deploy(configMap: WarpRouteDeployConfigMailboxRequired) { let tokenMetadataMap: TokenMetadataMap; try { @@ -681,6 +739,8 @@ abstract class TokenDeployer< await this.setEverclearOutputAssets(configMap, deployedContractsMap); + await this.enrollRouters(configMap, deployedContractsMap); + await super.transferOwnership(deployedContractsMap, configMap); return deployedContractsMap; @@ -707,7 +767,7 @@ export class HypERC20Deployer extends TokenDeployer { router(contracts: HyperlaneContracts): TokenRouter { for (const key of objKeys(hypERC20factories)) { if (contracts[key]) { - return contracts[key]; + return contracts[key] as TokenRouter; } } throw new Error('No matching contract found'); diff --git a/typescript/sdk/src/token/tokenMetadataUtils.ts b/typescript/sdk/src/token/tokenMetadataUtils.ts index 79f2359fd51..3fe487cbbc3 100644 --- a/typescript/sdk/src/token/tokenMetadataUtils.ts +++ b/typescript/sdk/src/token/tokenMetadataUtils.ts @@ -17,6 +17,7 @@ import { isCollateralTokenConfig, isEverclearCollateralTokenConfig, isEverclearEthBridgeTokenConfig, + isMultiCollateralTokenConfig, isNativeTokenConfig, isTokenMetadata, isXERC20TokenConfig, @@ -63,6 +64,7 @@ export async function deriveTokenMetadata( if ( isCollateralTokenConfig(config) || + isMultiCollateralTokenConfig(config) || isXERC20TokenConfig(config) || isCctpTokenConfig(config) || isEverclearCollateralTokenConfig(config) diff --git a/typescript/sdk/src/token/types.ts b/typescript/sdk/src/token/types.ts index 818e913e127..63615f10c12 100644 --- a/typescript/sdk/src/token/types.ts +++ b/typescript/sdk/src/token/types.ts @@ -253,6 +253,27 @@ export const isSyntheticRebaseTokenConfig = isCompliant( SyntheticRebaseTokenConfigSchema, ); +/** + * Configuration for MultiCollateral (multi-router collateral routing). + * Direct 1-message atomic transfers between collateral routers. + */ +export const MultiCollateralTokenConfigSchema = + TokenMetadataSchema.partial().extend({ + type: z.literal(TokenType.multiCollateral), + token: z.string().describe('Collateral token address'), + /** Map of domain → router addresses to enroll */ + enrolledRouters: z + .record(RemoteRouterDomainOrChainNameSchema, z.array(ZHash)) + .optional(), + ...BaseMovableTokenConfigSchema.shape, + }); +export type MultiCollateralTokenConfig = z.infer< + typeof MultiCollateralTokenConfigSchema +>; +export const isMultiCollateralTokenConfig = isCompliant( + MultiCollateralTokenConfigSchema, +); + export const EverclearCollateralTokenConfigSchema = z.object({ type: z.literal(TokenType.collateralEverclear), ...CollateralTokenConfigSchema.omit({ type: true }).shape, @@ -340,6 +361,7 @@ const AllHypTokenConfigSchema = z.discriminatedUnion('type', [ CctpTokenConfigSchema, EverclearCollateralTokenConfigSchema, EverclearEthBridgeTokenConfigSchema, + MultiCollateralTokenConfigSchema, UnknownTokenConfigSchema, ]); @@ -445,7 +467,8 @@ export const WarpRouteDeployConfigSchema = z isCctpTokenConfig(config) || isXERC20TokenConfig(config) || isNativeTokenConfig(config) || - isEverclearTokenBridgeConfig(config), + isEverclearTokenBridgeConfig(config) || + isMultiCollateralTokenConfig(config), ) || entries.every(([_, config]) => isTokenMetadata(config)) ); }, WarpRouteDeployConfigSchemaErrors.NO_SYNTHETIC_ONLY) @@ -678,6 +701,7 @@ function extractCCIPIsmMap( const MovableTokenSchema = z.discriminatedUnion('type', [ CollateralTokenConfigSchema, + MultiCollateralTokenConfigSchema, NativeTokenConfigSchema, ]); export type MovableTokenConfig = z.infer; diff --git a/typescript/sdk/src/warp/WarpCore.test.ts b/typescript/sdk/src/warp/WarpCore.test.ts index 4ab9d60a7c2..15f02c9e8f5 100644 --- a/typescript/sdk/src/warp/WarpCore.test.ts +++ b/typescript/sdk/src/warp/WarpCore.test.ts @@ -378,10 +378,534 @@ describe('WarpCore', () => { Object.values(invalidCollateralXERC20LockboxToken || {})[0], ).to.equal('Insufficient collateral on destination'); + balanceStubs.forEach((s) => { + s.restore(); + }); + quoteStubs.forEach((s) => { + s.restore(); + }); + }); + + it('Validates destination token routing', async () => { + const balanceStubs = warpCore.tokens.map((t) => + sinon.stub(t, 'getBalance').resolves({ amount: MOCK_BALANCE } as any), + ); + const minimumTransferAmount = 10n; + const quoteStubs = warpCore.tokens.map((t) => + sinon.stub(t, 'getHypAdapter').returns({ + quoteTransferRemoteGas: () => + Promise.resolve({ igpQuote: MOCK_INTERCHAIN_QUOTE }), + isApproveRequired: () => Promise.resolve(false), + populateTransferRemoteTx: () => Promise.resolve({}), + getMinimumTransferAmount: () => Promise.resolve(minimumTransferAmount), + getBalance: () => Promise.resolve(MOCK_BALANCE), + getBridgedSupply: () => Promise.resolve(MOCK_BALANCE), + getMintLimit: () => Promise.resolve(MEDIUM_MOCK_BALANCE), + getMintMaxLimit: () => Promise.resolve(MEDIUM_MOCK_BALANCE), + isRevokeApprovalRequired: () => Promise.resolve(false), + } as any), + ); + + const invalidDestinationToken = await warpCore.validateTransfer({ + originTokenAmount: evmHypNative.amount(TRANSFER_AMOUNT), + destination: test2.name, + recipient: MOCK_ADDRESS, + sender: MOCK_ADDRESS, + destinationToken: evmHypCollateralFiat, + }); + expect(Object.values(invalidDestinationToken || {})[0]).to.equal( + `Destination token chain mismatch for ${test2.name}`, + ); + + const validDestinationToken = await warpCore.validateTransfer({ + originTokenAmount: evmHypNative.amount(TRANSFER_AMOUNT), + destination: test2.name, + recipient: MOCK_ADDRESS, + sender: MOCK_ADDRESS, + destinationToken: evmHypSynthetic, + }); + expect(validDestinationToken).to.be.null; + + balanceStubs.forEach((s) => { + s.restore(); + }); + quoteStubs.forEach((s) => { + s.restore(); + }); + }); + + it('Requires explicit destination token for ambiguous routes', async () => { + const ambiguousConfig = yamlParse( + fs.readFileSync('./src/warp/test-warp-core-config.yaml', 'utf-8'), + ); + const extraTest2Address = '0x9876543210987654321098765432109876543219'; + + const test1Token = ambiguousConfig.tokens.find( + (token: any) => + token.chainName === test1.name && + token.addressOrDenom === evmHypNative.addressOrDenom, + ); + test1Token.connections.push({ + token: `ethereum|${test2.name}|${extraTest2Address}`, + }); + ambiguousConfig.tokens.push({ + chainName: test2.name, + standard: TokenStandard.EvmHypSynthetic, + decimals: 18, + symbol: 'ETH2', + name: 'Ether 2', + addressOrDenom: extraTest2Address, + connections: [ + { + token: `ethereum|${test1.name}|${evmHypNative.addressOrDenom}`, + }, + ], + }); + + const ambiguousWarpCore = WarpCore.FromConfig( + multiProvider, + ambiguousConfig, + ); + const ambiguousOrigin = ambiguousWarpCore.findToken( + test1.name, + evmHypNative.addressOrDenom, + ); + const extraDestination = ambiguousWarpCore.findToken( + test2.name, + extraTest2Address, + ); + expect(ambiguousOrigin).to.not.be.null; + expect(extraDestination).to.not.be.null; + + const balanceStubs = ambiguousWarpCore.tokens.map((t) => + sinon.stub(t, 'getBalance').resolves({ amount: MOCK_BALANCE } as any), + ); + const minimumTransferAmount = 10n; + const quoteStubs = ambiguousWarpCore.tokens.map((t) => + sinon.stub(t, 'getHypAdapter').returns({ + quoteTransferRemoteGas: () => + Promise.resolve({ igpQuote: MOCK_INTERCHAIN_QUOTE }), + isApproveRequired: () => Promise.resolve(false), + populateTransferRemoteTx: () => Promise.resolve({}), + getMinimumTransferAmount: () => Promise.resolve(minimumTransferAmount), + getBalance: () => Promise.resolve(MOCK_BALANCE), + getBridgedSupply: () => Promise.resolve(MOCK_BALANCE), + getMintLimit: () => Promise.resolve(MEDIUM_MOCK_BALANCE), + getMintMaxLimit: () => Promise.resolve(MEDIUM_MOCK_BALANCE), + isRevokeApprovalRequired: () => Promise.resolve(false), + } as any), + ); + + const ambiguousValidation = await ambiguousWarpCore.validateTransfer({ + originTokenAmount: ambiguousOrigin!.amount(TRANSFER_AMOUNT), + destination: test2.name, + recipient: MOCK_ADDRESS, + sender: MOCK_ADDRESS, + }); + expect(Object.values(ambiguousValidation || {})[0]).to.equal( + `Ambiguous route to ${test2.name}; specify destination token`, + ); + + const explicitValidation = await ambiguousWarpCore.validateTransfer({ + originTokenAmount: ambiguousOrigin!.amount(TRANSFER_AMOUNT), + destination: test2.name, + recipient: MOCK_ADDRESS, + sender: MOCK_ADDRESS, + destinationToken: extraDestination!, + }); + expect(explicitValidation).to.be.null; + balanceStubs.forEach((s) => s.restore()); quoteStubs.forEach((s) => s.restore()); }); + it('Includes token fee in MultiCollateral approval debit', async () => { + const tokenFeeAmount = 123n; + const originalCollateralAddress = evmHypNative.collateralAddressOrDenom; + (evmHypNative as any).collateralAddressOrDenom = + evmHypNative.addressOrDenom; + + const originMultiStub = sinon + .stub(evmHypNative, 'isMultiCollateralToken') + .returns(true); + const destinationMultiStub = sinon + .stub(evmHypSynthetic, 'isMultiCollateralToken') + .returns(true); + + const quoteTransferRemoteToGas = sinon.stub().resolves({ + igpQuote: { amount: 1n }, + tokenFeeQuote: { + addressOrDenom: evmHypNative.addressOrDenom, + amount: tokenFeeAmount, + }, + }); + const isApproveRequired = sinon.stub().resolves(true); + const populateApproveTx = sinon.stub().resolves({}); + const populateTransferRemoteToTx = sinon.stub().resolves({}); + + const adapterStub = sinon.stub(evmHypNative, 'getHypAdapter').returns({ + quoteTransferRemoteToGas, + isApproveRequired, + populateApproveTx, + populateTransferRemoteToTx, + isRevokeApprovalRequired: () => Promise.resolve(false), + } as any); + + try { + const result = await warpCore.getTransferRemoteTxs({ + originTokenAmount: evmHypNative.amount(TRANSFER_AMOUNT), + destination: test2.name, + sender: MOCK_ADDRESS, + recipient: MOCK_ADDRESS, + destinationToken: evmHypSynthetic, + }); + + expect(result.length).to.equal(2); + sinon.assert.calledWithExactly( + isApproveRequired, + MOCK_ADDRESS, + evmHypNative.addressOrDenom, + TRANSFER_AMOUNT + tokenFeeAmount, + ); + sinon.assert.calledWithMatch(populateApproveTx, { + weiAmountOrId: TRANSFER_AMOUNT + tokenFeeAmount, + recipient: evmHypNative.addressOrDenom, + }); + } finally { + adapterStub.restore(); + originMultiStub.restore(); + destinationMultiStub.restore(); + (evmHypNative as any).collateralAddressOrDenom = + originalCollateralAddress; + } + }); + + it('Rejects MultiCollateral transfer tx generation when IGP fee denom is non-native', async () => { + const originalCollateralAddress = evmHypNative.collateralAddressOrDenom; + (evmHypNative as any).collateralAddressOrDenom = + evmHypNative.addressOrDenom; + + const originMultiStub = sinon + .stub(evmHypNative, 'isMultiCollateralToken') + .returns(true); + const destinationMultiStub = sinon + .stub(evmHypSynthetic, 'isMultiCollateralToken') + .returns(true); + + const adapterStub = sinon.stub(evmHypNative, 'getHypAdapter').returns({ + quoteTransferRemoteToGas: sinon.stub().resolves({ + igpQuote: { + amount: 1n, + addressOrDenom: evmHypNative.addressOrDenom, + }, + tokenFeeQuote: { + addressOrDenom: evmHypNative.addressOrDenom, + amount: 0n, + }, + }), + isApproveRequired: sinon.stub().resolves(false), + isRevokeApprovalRequired: sinon.stub().resolves(false), + populateTransferRemoteToTx: sinon.stub().resolves({}), + } as any); + + try { + let thrown: Error | undefined; + try { + await warpCore.getTransferRemoteTxs({ + originTokenAmount: evmHypNative.amount(TRANSFER_AMOUNT), + destination: test2.name, + sender: MOCK_ADDRESS, + recipient: MOCK_ADDRESS, + destinationToken: evmHypSynthetic, + }); + } catch (error) { + thrown = error as Error; + } + + expect(thrown).to.not.equal(undefined); + expect(thrown!.message).to.contain( + 'MultiCollateral transferRemoteTo requires native IGP fee', + ); + } finally { + adapterStub.restore(); + originMultiStub.restore(); + destinationMultiStub.restore(); + (evmHypNative as any).collateralAddressOrDenom = + originalCollateralAddress; + } + }); + + it('Checks destination collateral for MultiCollateral route using explicit destination token', async () => { + const originMultiStub = sinon + .stub(evmHypNative, 'isMultiCollateralToken') + .returns(true); + const destinationMultiStub = sinon + .stub(cwHypCollateral, 'isMultiCollateralToken') + .returns(true); + const destinationAdapterStub = sinon + .stub(cwHypCollateral, 'getAdapter') + .returns({ + getBalance: sinon.stub().resolves(10n), + } as any); + + try { + const smallResult = await warpCore.isDestinationCollateralSufficient({ + originTokenAmount: evmHypNative.amount(9n), + destination: cwHypCollateral.chainName, + destinationToken: cwHypCollateral, + }); + expect(smallResult).to.equal(true); + + const bigResult = await warpCore.isDestinationCollateralSufficient({ + originTokenAmount: evmHypNative.amount(11n), + destination: cwHypCollateral.chainName, + destinationToken: cwHypCollateral, + }); + expect(bigResult).to.equal(false); + } finally { + destinationAdapterStub.restore(); + originMultiStub.restore(); + destinationMultiStub.restore(); + } + }); + + it('Adds revoke before approval for MultiCollateral when allowance must be reset', async () => { + const tokenFeeAmount = 123n; + const originalCollateralAddress = evmHypNative.collateralAddressOrDenom; + (evmHypNative as any).collateralAddressOrDenom = + evmHypNative.addressOrDenom; + + const originMultiStub = sinon + .stub(evmHypNative, 'isMultiCollateralToken') + .returns(true); + const destinationMultiStub = sinon + .stub(evmHypSynthetic, 'isMultiCollateralToken') + .returns(true); + + const quoteTransferRemoteToGas = sinon.stub().resolves({ + igpQuote: { amount: 1n }, + tokenFeeQuote: { + addressOrDenom: evmHypNative.addressOrDenom, + amount: tokenFeeAmount, + }, + }); + const isApproveRequired = sinon.stub().resolves(true); + const isRevokeApprovalRequired = sinon.stub().resolves(true); + const populateApproveTx = sinon.stub().resolves({}); + const populateTransferRemoteToTx = sinon.stub().resolves({}); + + const adapterStub = sinon.stub(evmHypNative, 'getHypAdapter').returns({ + quoteTransferRemoteToGas, + isApproveRequired, + isRevokeApprovalRequired, + populateApproveTx, + populateTransferRemoteToTx, + } as any); + + try { + const result = await warpCore.getTransferRemoteTxs({ + originTokenAmount: evmHypNative.amount(TRANSFER_AMOUNT), + destination: test2.name, + sender: MOCK_ADDRESS, + recipient: MOCK_ADDRESS, + destinationToken: evmHypSynthetic, + }); + + expect(result.length).to.equal(3); + expect(result[0].category).to.equal(WarpTxCategory.Revoke); + expect(result[1].category).to.equal(WarpTxCategory.Approval); + expect(result[2].category).to.equal(WarpTxCategory.Transfer); + + sinon.assert.calledWithMatch(populateApproveTx.firstCall, { + weiAmountOrId: 0, + }); + sinon.assert.calledWithMatch(populateApproveTx.secondCall, { + weiAmountOrId: TRANSFER_AMOUNT + tokenFeeAmount, + }); + } finally { + adapterStub.restore(); + originMultiStub.restore(); + destinationMultiStub.restore(); + (evmHypNative as any).collateralAddressOrDenom = + originalCollateralAddress; + } + }); + + it('Uses destination router-aware quote for MultiCollateral fees', async () => { + const originMultiStub = sinon + .stub(evmHypNative, 'isMultiCollateralToken') + .returns(true); + const destinationMultiStub = sinon + .stub(evmHypSynthetic, 'isMultiCollateralToken') + .returns(true); + + const quoteTransferRemoteToGas = sinon.stub().resolves({ + igpQuote: { amount: 42n }, + tokenFeeQuote: { + addressOrDenom: evmHypNative.addressOrDenom, + amount: 11n, + }, + }); + + const adapterStub = sinon.stub(evmHypNative, 'getHypAdapter').returns({ + quoteTransferRemoteToGas, + } as any); + + try { + const quote = await warpCore.getInterchainTransferFee({ + originTokenAmount: evmHypNative.amount(TRANSFER_AMOUNT), + destination: test2.name, + sender: MOCK_ADDRESS, + recipient: MOCK_ADDRESS, + destinationToken: evmHypSynthetic, + }); + + expect(quote.igpQuote.amount).to.equal(42n); + expect(quote.tokenFeeQuote?.amount).to.equal(11n); + sinon.assert.calledWithMatch(quoteTransferRemoteToGas, { + destination: test2.domainId, + recipient: MOCK_ADDRESS, + amount: TRANSFER_AMOUNT, + targetRouter: evmHypSynthetic.addressOrDenom, + }); + } finally { + adapterStub.restore(); + originMultiStub.restore(); + destinationMultiStub.restore(); + } + }); + + it('uses quoted interchain fee token for MultiCollateral estimateTransferRemoteFees', async () => { + const originMultiStub = sinon + .stub(evmHypNative, 'isMultiCollateralToken') + .returns(true); + const destinationMultiStub = sinon + .stub(evmHypSynthetic, 'isMultiCollateralToken') + .returns(true); + const quoteTransferRemoteToGas = sinon.stub().resolves({ + igpQuote: { + amount: 42n, + addressOrDenom: evmHypNative.addressOrDenom, + }, + }); + const adapterStub = sinon.stub(evmHypNative, 'getHypAdapter').returns({ + quoteTransferRemoteToGas, + } as any); + const localFeeAmountStub = sinon + .stub(warpCore as any, 'getLocalTransferFeeAmount') + .resolves(evmHypNative.amount(7n)); + + try { + const quote = await warpCore.estimateTransferRemoteFees({ + originTokenAmount: evmHypNative.amount(TRANSFER_AMOUNT), + destination: test2.name, + sender: MOCK_ADDRESS, + recipient: MOCK_ADDRESS, + destinationToken: evmHypSynthetic, + }); + + expect(quote.interchainQuote.amount).to.equal(42n); + expect(quote.interchainQuote.token.addressOrDenom).to.equal( + evmHypNative.addressOrDenom, + ); + expect(quote.localQuote.amount).to.equal(7n); + } finally { + localFeeAmountStub.restore(); + adapterStub.restore(); + destinationMultiStub.restore(); + originMultiStub.restore(); + } + }); + + it('Rejects non-connected destination token for MultiCollateral fee quote', async () => { + const originMultiStub = sinon + .stub(evmHypNative, 'isMultiCollateralToken') + .returns(true); + const quoteTransferRemoteToGas = sinon.stub().resolves({ + igpQuote: { amount: 42n }, + }); + const adapterStub = sinon.stub(evmHypNative, 'getHypAdapter').returns({ + quoteTransferRemoteToGas, + } as any); + + const invalidDestinationToken = new Token({ + ...evmHypSynthetic, + addressOrDenom: '0x9999999999999999999999999999999999999999', + }); + const invalidDestinationMultiStub = sinon + .stub(invalidDestinationToken, 'isMultiCollateralToken') + .returns(true); + + try { + let error: Error | undefined; + try { + await warpCore.getInterchainTransferFee({ + originTokenAmount: evmHypNative.amount(TRANSFER_AMOUNT), + destination: test2.name, + sender: MOCK_ADDRESS, + recipient: MOCK_ADDRESS, + destinationToken: invalidDestinationToken, + }); + } catch (e) { + error = e as Error; + } + + expect(error).to.exist; + expect(error!.message).to.contain('is not connected'); + expect(quoteTransferRemoteToGas.called).to.equal(false); + } finally { + invalidDestinationMultiStub.restore(); + adapterStub.restore(); + originMultiStub.restore(); + } + }); + + it('Converts destination minimum transfer amount into origin decimals correctly', async () => { + const destinationAdapterStub = sinon + .stub(sealevelHypSynthetic, 'getAdapter') + .returns({ + getMinimumTransferAmount: () => Promise.resolve(1_000_000_000n), + } as any); + + try { + const belowMinimum = await ( + warpCore as unknown as { + validateAmount: ( + originTokenAmount: TokenAmount, + destination: ChainName, + recipient: string, + destinationToken?: Token, + ) => Promise | null>; + } + ).validateAmount( + evmHypNative.amount(500_000_000_000_000_000n), + testSealevelChain.name, + MOCK_ADDRESS, + sealevelHypSynthetic, + ); + expect(belowMinimum?.amount).to.contain('Minimum transfer amount'); + + const atMinimum = await ( + warpCore as unknown as { + validateAmount: ( + originTokenAmount: TokenAmount, + destination: ChainName, + recipient: string, + destinationToken?: Token, + ) => Promise | null>; + } + ).validateAmount( + evmHypNative.amount(1_000_000_000_000_000_000n), + testSealevelChain.name, + MOCK_ADDRESS, + sealevelHypSynthetic, + ); + expect(atMinimum).to.be.null; + } finally { + destinationAdapterStub.restore(); + } + }); + it('Gets transfer remote txs', async () => { const coreStub = sinon .stub(warpCore, 'isApproveRequired') diff --git a/typescript/sdk/src/warp/WarpCore.ts b/typescript/sdk/src/warp/WarpCore.ts index 3f52b3cc319..917efb68f3f 100644 --- a/typescript/sdk/src/warp/WarpCore.ts +++ b/typescript/sdk/src/warp/WarpCore.ts @@ -30,6 +30,7 @@ import { TOKEN_STANDARD_TO_PROVIDER_TYPE, TokenStandard, } from '../token/TokenStandard.js'; +import { EvmHypMultiCollateralAdapter } from '../token/adapters/EvmMultiCollateralAdapter.js'; import { EVM_TRANSFER_REMOTE_GAS_ESTIMATE, EvmHypCollateralFiatAdapter, @@ -137,11 +138,13 @@ export class WarpCore { destination, sender, recipient, + destinationToken, }: { originTokenAmount: TokenAmount; destination: ChainNameOrId; sender?: Address; recipient: Address; + destinationToken?: IToken; }): Promise<{ igpQuote: TokenAmount; tokenFeeQuote?: TokenAmount }> { this.logger.debug(`Fetching interchain transfer quote to ${destination}`); const { amount, token: originToken } = originTokenAmount; @@ -161,18 +164,41 @@ export class WarpCore { gasAddressOrDenom = defaultQuote.addressOrDenom; } else { // Otherwise, compute IGP quote via the adapter - const hypAdapter = originToken.getHypAdapter( - this.multiProvider, - destinationName, - ); + let quote: InterchainGasQuote; const destinationDomainId = this.multiProvider.getDomainId(destination); - const quote = await hypAdapter.quoteTransferRemoteGas({ - destination: destinationDomainId, - sender, - customHook: originToken.igpTokenAddressOrDenom, - recipient, - amount, - }); + if (this.isMultiCollateralTransfer(originToken, destinationToken)) { + const resolvedDestinationToken = this.resolveDestinationToken({ + originToken, + destination, + destinationToken, + }); + assert( + resolvedDestinationToken.addressOrDenom, + 'Destination token missing addressOrDenom', + ); + const multiCollateralAdapter = originToken.getHypAdapter( + this.multiProvider, + destinationName, + ) as EvmHypMultiCollateralAdapter; + quote = await multiCollateralAdapter.quoteTransferRemoteToGas({ + destination: destinationDomainId, + recipient, + amount, + targetRouter: resolvedDestinationToken.addressOrDenom, + }); + } else { + const hypAdapter = originToken.getHypAdapter( + this.multiProvider, + destinationName, + ); + quote = await hypAdapter.quoteTransferRemoteGas({ + destination: destinationDomainId, + sender, + customHook: originToken.igpTokenAddressOrDenom, + recipient, + amount, + }); + } gasAmount = BigInt(quote.igpQuote.amount); gasAddressOrDenom = quote.igpQuote.addressOrDenom; feeAmount = quote.tokenFeeQuote?.amount; @@ -224,6 +250,7 @@ export class WarpCore { senderPubKey, interchainFee, tokenFeeQuote, + destinationToken, }: { originToken: IToken; destination: ChainNameOrId; @@ -231,6 +258,7 @@ export class WarpCore { senderPubKey?: HexString; interchainFee?: TokenAmount; tokenFeeQuote?: TokenAmount; + destinationToken?: IToken; }): Promise { this.logger.debug(`Estimating local transfer gas to ${destination}`); const originMetadata = this.multiProvider.getChainMetadata( @@ -273,6 +301,7 @@ export class WarpCore { recipient, interchainFee, tokenFeeQuote, + destinationToken, }); // Starknet does not support gas estimation without starknet account @@ -330,6 +359,7 @@ export class WarpCore { senderPubKey, interchainFee, tokenFeeQuote, + destinationToken, }: { originToken: IToken; destination: ChainNameOrId; @@ -337,6 +367,7 @@ export class WarpCore { senderPubKey?: HexString; interchainFee?: TokenAmount; tokenFeeQuote?: TokenAmount; + destinationToken?: IToken; }): Promise { const originMetadata = this.multiProvider.getChainMetadata( originToken.chainName, @@ -356,6 +387,7 @@ export class WarpCore { senderPubKey, interchainFee, tokenFeeQuote, + destinationToken, }); // Get the local gas token. This assumes the chain's native token will pay for local gas @@ -375,6 +407,7 @@ export class WarpCore { recipient, interchainFee, tokenFeeQuote, + destinationToken, }: { originTokenAmount: TokenAmount; destination: ChainNameOrId; @@ -382,7 +415,23 @@ export class WarpCore { recipient: Address; interchainFee?: TokenAmount; tokenFeeQuote?: TokenAmount; + destinationToken?: IToken; }): Promise> { + // Check if this is a MultiCollateral transfer + if ( + destinationToken && + this.isMultiCollateralTransfer(originTokenAmount.token, destinationToken) + ) { + return this.getMultiCollateralTransferTxs({ + originTokenAmount, + destination, + sender, + recipient, + destinationToken, + }); + } + + // Standard warp route transfer const transactions: Array = []; const { token, amount } = originTokenAmount; @@ -397,6 +446,7 @@ export class WarpCore { destination, sender, recipient, + destinationToken, }); interchainFee = transferFee.igpQuote; tokenFeeQuote = transferFee.tokenFeeQuote; @@ -520,6 +570,127 @@ export class WarpCore { return transactions; } + /** + * Check if this is a MultiCollateral transfer. + * Returns true if both tokens are MultiCollateral tokens. + */ + protected isMultiCollateralTransfer( + originToken: IToken, + destinationToken?: IToken, + ): destinationToken is IToken { + if (!destinationToken) return false; + return ( + originToken.isMultiCollateralToken() && + destinationToken.isMultiCollateralToken() + ); + } + + /** + * Executes a MultiCollateral transfer between different collateral routers. + * Uses transferRemoteTo for both same-chain and cross-chain transfers. + * Same-chain: calls handle() directly on target router (atomic, no relay needed). + */ + protected async getMultiCollateralTransferTxs({ + originTokenAmount, + destination, + sender, + recipient, + destinationToken, + }: { + originTokenAmount: TokenAmount; + destination: ChainNameOrId; + sender: Address; + recipient: Address; + destinationToken: IToken; + }): Promise> { + const transactions: Array = []; + const { token: originToken, amount } = originTokenAmount; + const destinationName = this.multiProvider.getChainName(destination); + const resolvedDestinationToken = this.resolveDestinationToken({ + originToken, + destination, + destinationToken, + }); + + assert( + originToken.collateralAddressOrDenom, + 'Origin token missing collateralAddressOrDenom', + ); + assert( + resolvedDestinationToken.addressOrDenom, + 'Destination token missing addressOrDenom', + ); + + const providerType = TOKEN_STANDARD_TO_PROVIDER_TYPE[originToken.standard]; + + const adapter = originToken.getHypAdapter( + this.multiProvider, + destinationName, + ) as EvmHypMultiCollateralAdapter; + + const transferQuote = await adapter.quoteTransferRemoteToGas({ + destination: this.multiProvider.getDomainId(destination), + recipient, + amount, + targetRouter: resolvedDestinationToken.addressOrDenom, + }); + assert( + !transferQuote.igpQuote.addressOrDenom || + isZeroishAddress(transferQuote.igpQuote.addressOrDenom), + `MultiCollateral transferRemoteTo requires native IGP fee; got ${transferQuote.igpQuote.addressOrDenom}`, + ); + const tokenFeeAmount = transferQuote.tokenFeeQuote?.amount ?? 0n; + const totalDebit = amount + tokenFeeAmount; + + const [isApproveRequired, isRevokeApprovalRequired] = await Promise.all([ + adapter.isApproveRequired(sender, originToken.addressOrDenom, totalDebit), + adapter.isRevokeApprovalRequired(sender, originToken.addressOrDenom), + ]); + + if (isApproveRequired && isRevokeApprovalRequired) { + const revokeTxReq = await adapter.populateApproveTx({ + weiAmountOrId: 0, + recipient: originToken.addressOrDenom, + }); + transactions.push({ + category: WarpTxCategory.Revoke, + type: providerType, + transaction: revokeTxReq, + } as WarpTypedTransaction); + } + + if (isApproveRequired) { + const approveTxReq = await adapter.populateApproveTx({ + weiAmountOrId: totalDebit, + recipient: originToken.addressOrDenom, + }); + transactions.push({ + category: WarpTxCategory.Approval, + type: providerType, + transaction: approveTxReq, + } as WarpTypedTransaction); + } + + // transferRemoteTo works for both same-chain and cross-chain. + // Same-chain: calls handle() directly on target router (atomic, no relay needed). + const destinationDomainId = this.multiProvider.getDomainId(destination); + + const txReq = await adapter.populateTransferRemoteToTx({ + destination: destinationDomainId, + recipient, + amount, + targetRouter: resolvedDestinationToken.addressOrDenom, + interchainGas: transferQuote, + }); + transactions.push({ + category: WarpTxCategory.Transfer, + type: providerType, + transaction: txReq, + } as WarpTypedTransaction); + + return transactions; + } + /** * Fetch local and interchain fee estimates for a remote transfer */ @@ -529,15 +700,31 @@ export class WarpCore { recipient, sender, senderPubKey, + destinationToken, }: { originTokenAmount: TokenAmount; destination: ChainNameOrId; recipient: Address; sender: Address; senderPubKey?: HexString; + destinationToken?: IToken; }): Promise { this.logger.debug('Fetching remote transfer fee estimates'); + const { token: originToken } = originTokenAmount; + + // Handle MultiCollateral fee estimation + if (this.isMultiCollateralTransfer(originToken, destinationToken)) { + return this.estimateMultiCollateralFees({ + originTokenAmount, + destination, + destinationToken, + recipient, + sender, + senderPubKey, + }); + } + // First get interchain gas quote (aka IGP quote) // Start with this because it's used in the local fee estimation const { igpQuote, tokenFeeQuote } = await this.getInterchainTransferFee({ @@ -564,6 +751,57 @@ export class WarpCore { }; } + /** + * Estimate fees for a MultiCollateral transfer. + */ + protected async estimateMultiCollateralFees({ + originTokenAmount, + destination, + destinationToken, + recipient, + sender, + senderPubKey, + }: { + originTokenAmount: TokenAmount; + destination: ChainNameOrId; + destinationToken: IToken; + recipient: Address; + sender: Address; + senderPubKey?: HexString; + }): Promise { + const { token: originToken } = originTokenAmount; + const resolvedDestinationToken = this.resolveDestinationToken({ + originToken, + destination, + destinationToken, + }); + + const { igpQuote: interchainQuote, tokenFeeQuote } = + await this.getInterchainTransferFee({ + originTokenAmount, + destination, + sender, + recipient, + destinationToken: resolvedDestinationToken, + }); + + const localQuote = await this.getLocalTransferFeeAmount({ + originToken, + destination, + sender, + senderPubKey, + interchainFee: interchainQuote, + tokenFeeQuote, + destinationToken, + }); + + return { + interchainQuote, + localQuote, + tokenFeeQuote, + }; + } + /** * Computes the max transferrable amount of the from the given * token balance, accounting for local and interchain gas fees @@ -575,6 +813,7 @@ export class WarpCore { sender, senderPubKey, feeEstimate, + destinationToken, }: { balance: TokenAmount; destination: ChainNameOrId; @@ -582,6 +821,7 @@ export class WarpCore { sender: Address; senderPubKey?: HexString; feeEstimate?: WarpCoreFeeEstimate; + destinationToken?: IToken; }): Promise { const originToken = balance.token; @@ -592,6 +832,7 @@ export class WarpCore { recipient, sender, senderPubKey, + destinationToken, }); } const { localQuote, interchainQuote, tokenFeeQuote } = feeEstimate; @@ -611,6 +852,7 @@ export class WarpCore { destination, recipient, sender, + destinationToken, }); // Because tokenFeeQuote is calculated based on the amount, we need to recalculate // the tokenFeeQuote after subtracting the localQuote and IGP to get max transfer amount @@ -642,31 +884,40 @@ export class WarpCore { async isDestinationCollateralSufficient({ originTokenAmount, destination, + destinationToken, }: { originTokenAmount: TokenAmount; destination: ChainNameOrId; + destinationToken?: IToken; }): Promise { const { token: originToken, amount } = originTokenAmount; - const destinationName = this.multiProvider.getChainName(destination); this.logger.debug( `Checking collateral for ${originToken.symbol} to ${destination}`, ); - const destinationToken = - originToken.getConnectionForChain(destinationName)?.token; - assert(destinationToken, `No connection found for ${destinationName}`); + const resolvedDestinationToken = this.resolveDestinationToken({ + originToken, + destination, + destinationToken, + }); - if (!TOKEN_COLLATERALIZED_STANDARDS.includes(destinationToken.standard)) { + if ( + !TOKEN_COLLATERALIZED_STANDARDS.includes( + resolvedDestinationToken.standard, + ) + ) { this.logger.debug( - `${destinationToken.symbol} is not collateralized, skipping`, + `${resolvedDestinationToken.symbol} is not collateralized, skipping`, ); return true; } - const destinationBalance = await this.getTokenCollateral(destinationToken); + const destinationBalance = await this.getTokenCollateral( + resolvedDestinationToken, + ); const destinationBalanceInOriginDecimals = convertDecimalsToIntegerString( - destinationToken.decimals, + resolvedDestinationToken.decimals, originToken.decimals, destinationBalance.toString(), ); @@ -674,13 +925,13 @@ export class WarpCore { // check for scaling factor if ( originToken.scale && - destinationToken.scale && - originToken.scale !== destinationToken.scale + resolvedDestinationToken.scale && + originToken.scale !== resolvedDestinationToken.scale ) { const precisionFactor = 100_000; const scaledAmount = convertToScaledAmount({ fromScale: originToken.scale, - toScale: destinationToken.scale, + toScale: resolvedDestinationToken.scale, amount, precisionFactor, }); @@ -734,12 +985,14 @@ export class WarpCore { recipient, sender, senderPubKey, + destinationToken, }: { originTokenAmount: TokenAmount; destination: ChainNameOrId; recipient: Address; sender: Address; senderPubKey?: HexString; + destinationToken?: IToken; }): Promise | null> { const chainError = this.validateChains( originTokenAmount.token.chainName, @@ -750,22 +1003,42 @@ export class WarpCore { const recipientError = this.validateRecipient(recipient, destination); if (recipientError) return recipientError; + const resolvedDestinationToken = (() => { + try { + return this.resolveDestinationToken({ + originToken: originTokenAmount.token, + destination, + destinationToken, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Invalid destination token'; + return { error: message }; + } + })(); + if ('error' in resolvedDestinationToken) { + return { destinationToken: resolvedDestinationToken.error }; + } + const amountError = await this.validateAmount( originTokenAmount, destination, recipient, + resolvedDestinationToken, ); if (amountError) return amountError; const destinationRateLimitError = await this.validateDestinationRateLimit( originTokenAmount, destination, + resolvedDestinationToken, ); if (destinationRateLimitError) return destinationRateLimitError; const destinationCollateralError = await this.validateDestinationCollateral( originTokenAmount, destination, + resolvedDestinationToken, ); if (destinationCollateralError) return destinationCollateralError; @@ -779,6 +1052,7 @@ export class WarpCore { sender, recipient, senderPubKey, + resolvedDestinationToken, ); if (balancesError) return balancesError; @@ -849,6 +1123,7 @@ export class WarpCore { originTokenAmount: TokenAmount, destination: ChainNameOrId, recipient: Address, + destinationToken?: IToken, ): Promise | null> { if (!originTokenAmount.amount || originTokenAmount.amount < 0n) { const isNft = originTokenAmount.token.isNft(); @@ -859,21 +1134,24 @@ export class WarpCore { const originToken = originTokenAmount.token; - const destinationName = this.multiProvider.getChainName(destination); - const destinationToken = - originToken.getConnectionForChain(destinationName)?.token; - assert(destinationToken, `No connection found for ${destinationName}`); - const destinationAdapter = destinationToken.getAdapter(this.multiProvider); + const resolvedDestinationToken = this.resolveDestinationToken({ + originToken, + destination, + destinationToken, + }); + const destinationAdapter = resolvedDestinationToken.getAdapter( + this.multiProvider, + ); // Get the min required destination amount const minDestinationTransferAmount = await destinationAdapter.getMinimumTransferAmount(recipient); // Convert the minDestinationTransferAmount to an origin amount - const minOriginTransferAmount = destinationToken.amount( + const minOriginTransferAmount = originToken.amount( convertDecimalsToIntegerString( + resolvedDestinationToken.decimals, originToken.decimals, - destinationToken.decimals, minDestinationTransferAmount.toString(), ), ); @@ -898,6 +1176,7 @@ export class WarpCore { sender: Address, recipient: Address, senderPubKey?: HexString, + destinationToken?: IToken, ): Promise | null> { const { token: originToken, amount } = originTokenAmount; @@ -919,6 +1198,7 @@ export class WarpCore { destination, sender, recipient, + destinationToken, }); // Get balance of the IGP fee token, which may be different from the transfer token const interchainQuoteTokenBalance = originToken.isFungibleWith( @@ -951,6 +1231,7 @@ export class WarpCore { senderPubKey, interchainFee: interchainQuote, tokenFeeQuote, + destinationToken, }); const feeEstimate = { interchainQuote, localQuote }; @@ -963,6 +1244,7 @@ export class WarpCore { sender, senderPubKey, feeEstimate, + destinationToken, }); if (amount > maxTransfer.amount) { return { amount: 'Insufficient balance for gas and transfer' }; @@ -977,10 +1259,12 @@ export class WarpCore { protected async validateDestinationCollateral( originTokenAmount: TokenAmount, destination: ChainNameOrId, + destinationToken?: IToken, ): Promise | null> { const valid = await this.isDestinationCollateralSufficient({ originTokenAmount, destination, + destinationToken, }); if (!valid) { @@ -995,35 +1279,39 @@ export class WarpCore { protected async validateDestinationRateLimit( originTokenAmount: TokenAmount, destination: ChainNameOrId, + destinationToken?: IToken, ): Promise | null> { const { token: originToken, amount } = originTokenAmount; - const destinationName = this.multiProvider.getChainName(destination); - const destinationToken = - originToken.getConnectionForChain(destinationName)?.token; - assert(destinationToken, `No connection found for ${destinationName}`); + const resolvedDestinationToken = this.resolveDestinationToken({ + originToken, + destination, + destinationToken, + }); - if (!MINT_LIMITED_STANDARDS.includes(destinationToken.standard)) { + if (!MINT_LIMITED_STANDARDS.includes(resolvedDestinationToken.standard)) { this.logger.debug( - `${destinationToken.symbol} does not have rate limit constraint, skipping`, + `${resolvedDestinationToken.symbol} does not have rate limit constraint, skipping`, ); return null; } let destinationMintLimit: bigint = 0n; if ( - destinationToken.standard === TokenStandard.EvmHypVSXERC20 || - destinationToken.standard === TokenStandard.EvmHypVSXERC20Lockbox || - destinationToken.standard === TokenStandard.EvmHypXERC20 || - destinationToken.standard === TokenStandard.EvmHypXERC20Lockbox + resolvedDestinationToken.standard === TokenStandard.EvmHypVSXERC20 || + resolvedDestinationToken.standard === + TokenStandard.EvmHypVSXERC20Lockbox || + resolvedDestinationToken.standard === TokenStandard.EvmHypXERC20 || + resolvedDestinationToken.standard === TokenStandard.EvmHypXERC20Lockbox ) { - const adapter = destinationToken.getAdapter( + const adapter = resolvedDestinationToken.getAdapter( this.multiProvider, ) as IHypXERC20Adapter; destinationMintLimit = await adapter.getMintLimit(); if ( - destinationToken.standard === TokenStandard.EvmHypVSXERC20 || - destinationToken.standard === TokenStandard.EvmHypVSXERC20Lockbox + resolvedDestinationToken.standard === TokenStandard.EvmHypVSXERC20 || + resolvedDestinationToken.standard === + TokenStandard.EvmHypVSXERC20Lockbox ) { const bufferCap = await adapter.getMintMaxLimit(); const max = bufferCap / 2n; @@ -1035,16 +1323,16 @@ export class WarpCore { } } } else if ( - destinationToken.standard === TokenStandard.EvmHypCollateralFiat + resolvedDestinationToken.standard === TokenStandard.EvmHypCollateralFiat ) { - const adapter = destinationToken.getAdapter( + const adapter = resolvedDestinationToken.getAdapter( this.multiProvider, ) as EvmHypCollateralFiatAdapter; destinationMintLimit = await adapter.getMintLimit(); } const destinationMintLimitInOriginDecimals = convertDecimalsToIntegerString( - destinationToken.decimals, + resolvedDestinationToken.decimals, originToken.decimals, destinationMintLimit.toString(), ); @@ -1082,6 +1370,51 @@ export class WarpCore { return null; } + protected resolveDestinationToken({ + originToken, + destination, + destinationToken, + }: { + originToken: IToken; + destination: ChainNameOrId; + destinationToken?: IToken; + }): IToken { + const destinationName = this.multiProvider.getChainName(destination); + const destinationCandidates = originToken + .getConnections() + .filter((connection) => connection.token.chainName === destinationName) + .map((connection) => connection.token); + + assert( + destinationCandidates.length > 0, + `No connection found for ${destinationName}`, + ); + + if (destinationToken) { + assert( + destinationToken.chainName === destinationName, + `Destination token chain mismatch for ${destinationName}`, + ); + const matchedToken = destinationCandidates.find( + (candidate) => + candidate.equals(destinationToken) || + candidate.addressOrDenom.toLowerCase() === + destinationToken.addressOrDenom.toLowerCase(), + ); + assert( + matchedToken, + `Destination token ${destinationToken.addressOrDenom} is not connected from ${originToken.chainName} to ${destinationName}`, + ); + return matchedToken; + } + + assert( + destinationCandidates.length === 1, + `Ambiguous route to ${destinationName}; specify destination token`, + ); + return destinationCandidates[0]; + } + /** * Search through token list to find token with matching chain and address */ diff --git a/typescript/warp-monitor/src/explorer.test.ts b/typescript/warp-monitor/src/explorer.test.ts new file mode 100644 index 00000000000..5eb16767f5d --- /dev/null +++ b/typescript/warp-monitor/src/explorer.test.ts @@ -0,0 +1,176 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { rootLogger } from '@hyperlane-xyz/utils'; + +import { + ExplorerPendingTransfersClient, + messageAmountToTokenBaseUnits, + normalizeExplorerAddress, + normalizeExplorerHex, + type RouterNodeMetadata, +} from './explorer.js'; + +describe('Explorer Pending Transfers', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('normalize helpers', () => { + it('normalizes postgres bytea hex', () => { + expect(normalizeExplorerHex('\\x1234')).to.equal('0x1234'); + expect(normalizeExplorerHex('0x1234')).to.equal('0x1234'); + }); + + it('normalizes padded 32-byte addresses to EVM address', () => { + const padded = + '0x0000000000000000000000001111111111111111111111111111111111111111'; + expect(normalizeExplorerAddress(padded)).to.equal( + '0x1111111111111111111111111111111111111111', + ); + }); + + it('converts message amount to token base units using scale', () => { + const messageAmount = 1234567890000000000n; + expect(messageAmountToTokenBaseUnits(messageAmount, 1)).to.equal( + messageAmount, + ); + expect( + messageAmountToTokenBaseUnits(messageAmount, 1_000_000_000_000), + ).to.equal(1234567n); + expect(messageAmountToTokenBaseUnits(100n, 1)).to.equal(100n); + expect(messageAmountToTokenBaseUnits(100n, 10)).to.equal(10n); + }); + + it('throws on invalid scale', () => { + expect(() => messageAmountToTokenBaseUnits(1n, 0)).to.throw( + 'Invalid token scale', + ); + }); + }); + + it('maps explorer inflight messages to destination nodes', async () => { + const router = '0x00000000000000000000000000000000000000aa'; + const nodes: RouterNodeMetadata[] = [ + { + nodeId: 'USDC|base|0xrouter', + chainName: 'base' as any, + domainId: 8453, + routerAddress: router, + tokenAddress: '0x00000000000000000000000000000000000000bb', + tokenName: 'USD Coin', + tokenSymbol: 'USDC', + tokenDecimals: 6, + tokenScale: 1_000_000_000_000, + token: {} as any, + }, + ]; + + const amountCanonical18 = 1234567890000000000n; + const amountHex = amountCanonical18.toString(16).padStart(64, '0'); + const recipientBytes32 = + '0000000000000000000000003333333333333333333333333333333333333333'; + const malformedRecipientBytes32 = + '1111111111111111111111113333333333333333333333333333333333333333'; + const messageBody = `0x${recipientBytes32}${amountHex}`; + const malformedRecipientBody = `0x${malformedRecipientBytes32}${amountHex}`; + + sinon.stub(globalThis, 'fetch' as any).resolves({ + ok: true, + json: async () => ({ + data: { + message_view: [ + { + msg_id: + '\\xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + origin_domain_id: 42161, + destination_domain_id: 8453, + sender: '\\xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + recipient: + '\\x00000000000000000000000000000000000000000000000000000000000000aa', + message_body: messageBody, + send_occurred_at: new Date(Date.now() - 60_000).toISOString(), + }, + // wrong destination router, should be ignored + { + msg_id: + '\\xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + origin_domain_id: 42161, + destination_domain_id: 8453, + sender: '\\xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + recipient: + '\\x00000000000000000000000000000000000000000000000000000000000000ff', + message_body: messageBody, + send_occurred_at: null, + }, + // malformed recipient bytes32, should be ignored + { + msg_id: + '\\xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + origin_domain_id: 42161, + destination_domain_id: 8453, + sender: '\\xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + recipient: + '\\x00000000000000000000000000000000000000000000000000000000000000aa', + message_body: malformedRecipientBody, + send_occurred_at: null, + }, + ], + }, + }), + } as any); + + const client = new ExplorerPendingTransfersClient( + 'https://explorer.example/v1/graphql', + nodes, + rootLogger, + ); + + const transfers = await client.getPendingDestinationTransfers(); + + expect(transfers).to.have.length(1); + expect(transfers[0].destinationNodeId).to.equal('USDC|base|0xrouter'); + expect(transfers[0].destinationDomainId).to.equal(8453); + expect(transfers[0].destinationRouter).to.equal(router); + expect(transfers[0].amountBaseUnits).to.equal(1234567n); + expect(transfers[0].sendOccurredAtMs).to.be.a('number'); + }); + + it('throws when explorer returns GraphQL errors', async () => { + const nodes: RouterNodeMetadata[] = [ + { + nodeId: 'USDC|base|0xrouter', + chainName: 'base' as any, + domainId: 8453, + routerAddress: '0x00000000000000000000000000000000000000aa', + tokenAddress: '0x00000000000000000000000000000000000000bb', + tokenName: 'USD Coin', + tokenSymbol: 'USDC', + tokenDecimals: 6, + token: {} as any, + }, + ]; + + sinon.stub(globalThis, 'fetch' as any).resolves({ + ok: true, + json: async () => ({ + errors: [{ message: 'boom' }], + }), + } as any); + + const client = new ExplorerPendingTransfersClient( + 'https://explorer.example/v1/graphql', + nodes, + rootLogger, + ); + + let thrown: Error | undefined; + try { + await client.getPendingDestinationTransfers(); + } catch (error) { + thrown = error as Error; + } + expect(thrown).to.not.equal(undefined); + expect(thrown!.message).to.contain('GraphQL errors'); + }); +}); diff --git a/typescript/warp-monitor/src/explorer.ts b/typescript/warp-monitor/src/explorer.ts new file mode 100644 index 00000000000..4315269b5ae --- /dev/null +++ b/typescript/warp-monitor/src/explorer.ts @@ -0,0 +1,269 @@ +import type { Logger } from 'pino'; + +import type { ChainName, Token } from '@hyperlane-xyz/sdk'; +import { + bytes32ToAddress, + isValidAddressEvm, + isZeroishAddress, + parseWarpRouteMessage, +} from '@hyperlane-xyz/utils'; + +type ExplorerMessageRow = { + msg_id: string; + origin_domain_id: number; + destination_domain_id: number; + sender: string; + recipient: string; + message_body: string; + send_occurred_at: string | null; +}; + +export type RouterNodeMetadata = { + nodeId: string; + chainName: ChainName; + domainId: number; + routerAddress: string; + tokenAddress: string; + tokenName: string; + tokenSymbol: string; + tokenDecimals: number; + tokenScale?: number; + token: Token; +}; + +export type PendingDestinationTransfer = { + messageId: string; + originDomainId: number; + destinationDomainId: number; + destinationChain: ChainName; + destinationNodeId: string; + destinationRouter: string; + amountBaseUnits: bigint; + sendOccurredAtMs?: number; +}; + +export function normalizeExplorerHex(hex: string): string { + if (!hex) return hex; + return hex.startsWith('\\x') ? `0x${hex.slice(2)}` : hex; +} + +export function normalizeExplorerAddress(address: string): string { + const normalized = normalizeExplorerHex(address).toLowerCase(); + if (!normalized.startsWith('0x')) return normalized; + // Explorer can return 32-byte padded addresses. Keep low 20 bytes for EVM. + if (normalized.length === 66) return `0x${normalized.slice(26)}`; + return normalized; +} + +function isValidEvmWarpRecipient(recipientBytes32: string): boolean { + const normalized = normalizeExplorerHex(recipientBytes32).toLowerCase(); + if (!/^0x[0-9a-f]{64}$/.test(normalized)) return false; + // EVM warp recipients should be left-padded 20-byte addresses. + if (!normalized.startsWith('0x000000000000000000000000')) return false; + + try { + const recipient = bytes32ToAddress(normalized); + return isValidAddressEvm(recipient) && !isZeroishAddress(recipient); + } catch { + return false; + } +} + +export function messageAmountToTokenBaseUnits( + amountMessageUnits: bigint, + tokenScale?: number, +): bigint { + const scale = BigInt(tokenScale ?? 1); + if (scale <= 0n) { + throw new Error(`Invalid token scale ${scale.toString()}`); + } + + return amountMessageUnits / scale; +} + +export class ExplorerPendingTransfersClient { + private readonly routers: string[]; + private readonly domains: number[]; + private readonly nodeByDestinationKey: Map; + + constructor( + private readonly apiUrl: string, + nodes: RouterNodeMetadata[], + private readonly logger: Logger, + ) { + const routers = new Set(); + const domains = new Set(); + this.nodeByDestinationKey = new Map(); + + for (const node of nodes) { + const routerLower = node.routerAddress.toLowerCase(); + routers.add(routerLower); + domains.add(node.domainId); + this.nodeByDestinationKey.set(`${node.domainId}:${routerLower}`, node); + } + + this.routers = [...routers]; + this.domains = [...domains]; + } + + async getPendingDestinationTransfers( + limit = 200, + ): Promise { + const rows = await this.queryInflightTransfers(limit); + const transfers: PendingDestinationTransfer[] = []; + + for (const row of rows) { + const destinationRouter = normalizeExplorerAddress(row.recipient); + const destinationKey = `${row.destination_domain_id}:${destinationRouter.toLowerCase()}`; + const node = this.nodeByDestinationKey.get(destinationKey); + if (!node) continue; + + let parsedMessage: ReturnType; + try { + parsedMessage = parseWarpRouteMessage( + normalizeExplorerHex(row.message_body), + ); + } catch (error) { + this.logger.debug( + { + messageId: row.msg_id, + destinationDomainId: row.destination_domain_id, + destinationRouter, + error: error instanceof Error ? error.message : String(error), + }, + 'Skipping explorer message with unparsable warp message body', + ); + continue; + } + if (!isValidEvmWarpRecipient(parsedMessage.recipient)) { + this.logger.debug( + { + messageId: row.msg_id, + destinationDomainId: row.destination_domain_id, + destinationRouter, + recipient: parsedMessage.recipient, + }, + 'Skipping explorer message with malformed recipient bytes32', + ); + continue; + } + + const sendOccurredAtMs = this.parseSendOccurredAt(row.send_occurred_at); + + transfers.push({ + messageId: normalizeExplorerHex(row.msg_id), + originDomainId: row.origin_domain_id, + destinationDomainId: row.destination_domain_id, + destinationChain: node.chainName, + destinationNodeId: node.nodeId, + destinationRouter, + amountBaseUnits: messageAmountToTokenBaseUnits( + parsedMessage.amount, + node.tokenScale, + ), + sendOccurredAtMs, + }); + } + + return transfers; + } + + private parseSendOccurredAt( + sendOccurredAt: string | null, + ): number | undefined { + if (!sendOccurredAt) return undefined; + const parsed = Date.parse(sendOccurredAt); + if (Number.isNaN(parsed)) return undefined; + return parsed; + } + + private toBytea(address: string): string { + return address.replace(/^0x/i, '\\x').toLowerCase(); + } + + private async queryInflightTransfers( + limit: number, + ): Promise { + if (this.routers.length === 0 || this.domains.length === 0) return []; + + const variables = { + senders: this.routers.map((router) => this.toBytea(router)), + recipients: this.routers.map((router) => this.toBytea(router)), + originDomains: this.domains, + destinationDomains: this.domains, + limit, + }; + + const query = ` + query WarpMonitorInflightTransfers( + $senders: [bytea!], + $recipients: [bytea!], + $originDomains: [Int!], + $destinationDomains: [Int!], + $limit: Int = 200 + ) { + message_view( + where: { + _and: [ + { is_delivered: { _eq: false } }, + { sender: { _in: $senders } }, + { recipient: { _in: $recipients } }, + { origin_domain_id: { _in: $originDomains } }, + { destination_domain_id: { _in: $destinationDomains } } + ] + } + order_by: { origin_tx_id: desc } + limit: $limit + ) { + msg_id + origin_domain_id + destination_domain_id + sender + recipient + message_body + send_occurred_at + } + } + `; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + + try { + const response = await fetch(this.apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + signal: controller.signal, + }); + + if (!response.ok) { + let details: string; + try { + details = JSON.stringify(await response.json()); + } catch { + details = await response.text(); + } + + throw new Error( + `Explorer query failed: ${response.status} ${response.statusText} ${details}`, + ); + } + + const payload: { + data?: { message_view?: ExplorerMessageRow[] }; + errors?: unknown; + } = await response.json(); + + if (payload.errors) { + throw new Error( + `Explorer query returned GraphQL errors: ${JSON.stringify(payload.errors)}`, + ); + } + + return payload.data?.message_view ?? []; + } finally { + clearTimeout(timeout); + } + } +} diff --git a/typescript/warp-monitor/src/metrics.test.ts b/typescript/warp-monitor/src/metrics.test.ts index 1699329fafc..801606f5ba8 100644 --- a/typescript/warp-monitor/src/metrics.test.ts +++ b/typescript/warp-monitor/src/metrics.test.ts @@ -10,7 +10,12 @@ import { TokenStandard } from '@hyperlane-xyz/sdk'; import { metricsRegister, + resetInventoryBalanceMetrics, + resetPendingDestinationMetrics, + updateInventoryBalanceMetrics, updateNativeWalletBalanceMetrics, + updatePendingDestinationMetrics, + updateProjectedDeficitMetrics, updateTokenBalanceMetrics, updateXERC20LimitsMetrics, } from './metrics.js'; @@ -301,4 +306,76 @@ describe('Warp Monitor Metrics', () => { ); }); }); + + describe('pending destination metrics', () => { + it('should record pending amount/count/age', async () => { + resetPendingDestinationMetrics(); + updatePendingDestinationMetrics({ + warpRouteId: 'MULTI/stableswap', + nodeId: 'USDC|base|0xrouter', + chainName: 'base', + routerAddress: '0xrouter', + tokenAddress: '0xtoken', + tokenSymbol: 'USDC', + tokenName: 'USD Coin', + pendingAmount: 123.45, + pendingCount: 3, + oldestPendingSeconds: 120, + }); + + const metrics = await metricsRegister.metrics(); + expect(metrics).to.include( + 'hyperlane_warp_route_pending_destination_amount', + ); + expect(metrics).to.include( + 'hyperlane_warp_route_pending_destination_count', + ); + expect(metrics).to.include( + 'hyperlane_warp_route_pending_destination_oldest_seconds', + ); + expect(metrics).to.include('node_id="USDC|base|0xrouter"'); + expect(metrics).to.include('token_symbol="USDC"'); + }); + + it('should record projected deficit separately', async () => { + resetPendingDestinationMetrics(); + updateProjectedDeficitMetrics({ + warpRouteId: 'MULTI/stableswap', + nodeId: 'USDC|base|0xrouter', + chainName: 'base', + routerAddress: '0xrouter', + tokenAddress: '0xtoken', + tokenSymbol: 'USDC', + tokenName: 'USD Coin', + projectedDeficit: 23.45, + }); + + const metrics = await metricsRegister.metrics(); + expect(metrics).to.include('hyperlane_warp_route_projected_deficit'); + expect(metrics).to.include('node_id="USDC|base|0xrouter"'); + expect(metrics).to.include('23.45'); + }); + }); + + describe('inventory balance metrics', () => { + it('should record configured inventory address balances', async () => { + resetInventoryBalanceMetrics(); + updateInventoryBalanceMetrics({ + warpRouteId: 'MULTI/stableswap', + nodeId: 'USDT|arbitrum|0xrouter2', + chainName: 'arbitrum', + routerAddress: '0xrouter2', + tokenAddress: '0xtoken2', + tokenSymbol: 'USDT', + tokenName: 'Tether USD', + inventoryAddress: '0xrebalancer', + inventoryBalance: 77.7, + }); + + const metrics = await metricsRegister.metrics(); + expect(metrics).to.include('hyperlane_warp_route_inventory_balance'); + expect(metrics).to.include('inventory_address="0xrebalancer"'); + expect(metrics).to.include('node_id="USDT|arbitrum|0xrouter2"'); + }); + }); }); diff --git a/typescript/warp-monitor/src/metrics.ts b/typescript/warp-monitor/src/metrics.ts index 7ef405b75e5..fced08a9bb4 100644 --- a/typescript/warp-monitor/src/metrics.ts +++ b/typescript/warp-monitor/src/metrics.ts @@ -1,4 +1,4 @@ -import { Registry } from 'prom-client'; +import { Gauge, Registry } from 'prom-client'; import { type NativeWalletBalance, @@ -21,6 +21,81 @@ export const metricsRegister = new Registry(); // Create shared gauges const gauges: WarpMetricsGauges = createWarpMetricsGauges(metricsRegister); +type BaseRouterMetric = { + warpRouteId: string; + nodeId: string; + chainName: string; + routerAddress: string; + tokenAddress: string; + tokenSymbol: string; + tokenName: string; +}; + +type PendingDestinationMetric = BaseRouterMetric & { + pendingAmount: number; + pendingCount: number; + oldestPendingSeconds: number; +}; + +type ProjectedDeficitMetric = BaseRouterMetric & { + projectedDeficit: number; +}; + +type InventoryBalanceMetric = BaseRouterMetric & { + inventoryAddress: string; + inventoryBalance: number; +}; + +const pendingMetricLabelNames = [ + 'warp_route_id', + 'node_id', + 'chain_name', + 'router_address', + 'token_address', + 'token_symbol', + 'token_name', +] as const; + +const inventoryMetricLabelNames = [ + ...pendingMetricLabelNames, + 'inventory_address', +] as const; + +const pendingDestinationAmountGauge = new Gauge({ + name: 'hyperlane_warp_route_pending_destination_amount', + help: 'Undelivered pending transfer amount owed by destination router', + registers: [metricsRegister], + labelNames: pendingMetricLabelNames, +}); + +const pendingDestinationCountGauge = new Gauge({ + name: 'hyperlane_warp_route_pending_destination_count', + help: 'Count of undelivered pending transfers for destination router', + registers: [metricsRegister], + labelNames: pendingMetricLabelNames, +}); + +const pendingDestinationOldestSecondsGauge = new Gauge({ + name: 'hyperlane_warp_route_pending_destination_oldest_seconds', + help: 'Age in seconds of the oldest undelivered pending transfer for destination router', + registers: [metricsRegister], + labelNames: pendingMetricLabelNames, +}); + +const projectedDeficitGauge = new Gauge({ + name: 'hyperlane_warp_route_projected_deficit', + help: 'Projected destination deficit = max(pending destination amount - router collateral, 0)', + registers: [metricsRegister], + labelNames: pendingMetricLabelNames, +}); + +const inventoryBalanceGauge = new Gauge({ + name: 'hyperlane_warp_route_inventory_balance', + help: 'Inventory balance held by configured address for each route node', + registers: [metricsRegister], + labelNames: inventoryMetricLabelNames, +}); + /** * Updates token balance metrics for a warp route token. */ @@ -94,3 +169,67 @@ export function updateXERC20LimitsMetrics( getLogger(), ); } + +export function resetPendingDestinationMetrics(): void { + pendingDestinationAmountGauge.reset(); + pendingDestinationCountGauge.reset(); + pendingDestinationOldestSecondsGauge.reset(); + projectedDeficitGauge.reset(); +} + +export function resetInventoryBalanceMetrics(): void { + inventoryBalanceGauge.reset(); +} + +export function updatePendingDestinationMetrics( + metric: PendingDestinationMetric, +): void { + const labels = { + warp_route_id: metric.warpRouteId, + node_id: metric.nodeId, + chain_name: metric.chainName, + router_address: metric.routerAddress, + token_address: metric.tokenAddress, + token_symbol: metric.tokenSymbol, + token_name: metric.tokenName, + }; + + pendingDestinationAmountGauge.labels(labels).set(metric.pendingAmount); + pendingDestinationCountGauge.labels(labels).set(metric.pendingCount); + pendingDestinationOldestSecondsGauge + .labels(labels) + .set(metric.oldestPendingSeconds); +} + +export function updateProjectedDeficitMetrics( + metric: ProjectedDeficitMetric, +): void { + const labels = { + warp_route_id: metric.warpRouteId, + node_id: metric.nodeId, + chain_name: metric.chainName, + router_address: metric.routerAddress, + token_address: metric.tokenAddress, + token_symbol: metric.tokenSymbol, + token_name: metric.tokenName, + }; + + projectedDeficitGauge.labels(labels).set(metric.projectedDeficit); +} + +export function updateInventoryBalanceMetrics( + metric: InventoryBalanceMetric, +): void { + const labels = { + warp_route_id: metric.warpRouteId, + node_id: metric.nodeId, + chain_name: metric.chainName, + router_address: metric.routerAddress, + token_address: metric.tokenAddress, + token_symbol: metric.tokenSymbol, + token_name: metric.tokenName, + inventory_address: metric.inventoryAddress, + }; + + inventoryBalanceGauge.labels(labels).set(metric.inventoryBalance); +} diff --git a/typescript/warp-monitor/src/monitor.test.ts b/typescript/warp-monitor/src/monitor.test.ts new file mode 100644 index 00000000000..9bd4e6adfca --- /dev/null +++ b/typescript/warp-monitor/src/monitor.test.ts @@ -0,0 +1,327 @@ +import { expect } from 'chai'; + +import type { IRegistry } from '@hyperlane-xyz/registry'; +import type { Token, WarpCore } from '@hyperlane-xyz/sdk'; +import type { + PendingDestinationTransfer, + RouterNodeMetadata, + ExplorerPendingTransfersClient, +} from './explorer.js'; + +import { + resetInventoryBalanceMetrics, + resetPendingDestinationMetrics, + metricsRegister, +} from './metrics.js'; +import { WarpMonitor } from './monitor.js'; + +function createMockToken({ + collateralized, + decimals, + getBalance = async () => 0n, +}: { + collateralized: boolean; + decimals: number; + getBalance?: () => Promise; +}): Token { + return { + isCollateralized: () => collateralized, + amount: ((amount: bigint) => ({ + getDecimalFormattedAmount: () => Number(amount) / 10 ** decimals, + })) as Token['amount'], + getAdapter: (() => ({ + getBalance, + })) as unknown as Token['getAdapter'], + } as Token; +} + +async function invokeUpdatePendingAndInventoryMetrics( + monitor: WarpMonitor, + warpCore: WarpCore, + routerNodes: RouterNodeMetadata[], + collateralByNodeId: Map, + warpRouteId: string, + pendingTransfersClient?: ExplorerPendingTransfersClient, + explorerQueryLimit?: number, + inventoryAddress?: string, +) { + const updatePendingAndInventoryMetrics = (monitor as any) + .updatePendingAndInventoryMetrics as ( + warpCore: WarpCore, + routerNodes: RouterNodeMetadata[], + collateralByNodeId: Map, + warpRouteId: string, + pendingTransfersClient?: ExplorerPendingTransfersClient, + explorerQueryLimit?: number, + inventoryAddress?: string, + ) => Promise; + + await updatePendingAndInventoryMetrics.call( + monitor, + warpCore, + routerNodes, + collateralByNodeId, + warpRouteId, + pendingTransfersClient, + explorerQueryLimit, + inventoryAddress, + ); +} + +describe('WarpMonitor', () => { + afterEach(() => { + resetPendingDestinationMetrics(); + resetInventoryBalanceMetrics(); + }); + + it('emits projected deficit metrics only for collateralized nodes', async () => { + const monitor = new WarpMonitor( + { + warpRouteId: 'MULTI/deficit-test', + checkFrequency: 10_000, + }, + {} as IRegistry, + ); + + const collateralizedNodeId = 'COLLAT|anvil2|0xroutera'; + const nonCollateralizedNodeId = 'SYNTH|anvil2|0xrouterb'; + const routerNodes: RouterNodeMetadata[] = [ + { + nodeId: collateralizedNodeId, + chainName: 'anvil2' as RouterNodeMetadata['chainName'], + domainId: 31337, + routerAddress: '0xroutera', + tokenAddress: '0xtokena', + tokenName: 'Collateral Token', + tokenSymbol: 'COLLAT', + tokenDecimals: 6, + token: createMockToken({ + collateralized: true, + decimals: 6, + }), + }, + { + nodeId: nonCollateralizedNodeId, + chainName: 'anvil2' as RouterNodeMetadata['chainName'], + domainId: 31337, + routerAddress: '0xrouterb', + tokenAddress: '0xtokenb', + tokenName: 'Synthetic Token', + tokenSymbol: 'SYNTH', + tokenDecimals: 6, + token: createMockToken({ + collateralized: false, + decimals: 6, + }), + }, + ]; + + const pendingTransfersClient: Pick< + ExplorerPendingTransfersClient, + 'getPendingDestinationTransfers' + > = { + async getPendingDestinationTransfers() { + return [ + { + messageId: '0xmsg1', + originDomainId: 31337, + destinationDomainId: 31337, + destinationChain: 'anvil2', + destinationNodeId: collateralizedNodeId, + destinationRouter: '0xroutera', + amountBaseUnits: 2_000_000n, + }, + { + messageId: '0xmsg2', + originDomainId: 31337, + destinationDomainId: 31337, + destinationChain: 'anvil2', + destinationNodeId: nonCollateralizedNodeId, + destinationRouter: '0xrouterb', + amountBaseUnits: 2_000_000n, + }, + ] satisfies PendingDestinationTransfer[]; + }, + }; + + const collateralByNodeId = new Map([ + [collateralizedNodeId, 1_000_000n], + [nonCollateralizedNodeId, 1_000_000n], + ]); + + await invokeUpdatePendingAndInventoryMetrics( + monitor, + { multiProvider: {} } as WarpCore, + routerNodes, + collateralByNodeId, + 'MULTI/deficit-test', + pendingTransfersClient as ExplorerPendingTransfersClient, + 200, + undefined, + ); + + const metrics = await metricsRegister.metrics(); + const pendingLines = metrics + .split('\n') + .filter((line) => + line.startsWith('hyperlane_warp_route_pending_destination_amount{'), + ); + expect( + pendingLines.some((line) => + line.includes(`node_id="${collateralizedNodeId}"`), + ), + ).to.equal(true); + expect( + pendingLines.some((line) => + line.includes(`node_id="${nonCollateralizedNodeId}"`), + ), + ).to.equal(true); + + const projectedLines = metrics + .split('\n') + .filter((line) => + line.startsWith('hyperlane_warp_route_projected_deficit{'), + ); + expect( + projectedLines.some((line) => + line.includes(`node_id="${collateralizedNodeId}"`), + ), + ).to.equal(true); + expect( + projectedLines.some((line) => + line.includes(`node_id="${nonCollateralizedNodeId}"`), + ), + ).to.equal(false); + }); + + it('does not emit inventory metrics when balance read fails', async () => { + const monitor = new WarpMonitor( + { + warpRouteId: 'MULTI/inventory-fail-test', + checkFrequency: 10_000, + }, + {} as IRegistry, + ); + + const nodeId = 'COLLAT|anvil2|0xroutera'; + const routerNodes: RouterNodeMetadata[] = [ + { + nodeId, + chainName: 'anvil2' as RouterNodeMetadata['chainName'], + domainId: 31337, + routerAddress: '0xroutera', + tokenAddress: '0xtokena', + tokenName: 'Collateral Token', + tokenSymbol: 'COLLAT', + tokenDecimals: 6, + token: createMockToken({ + collateralized: true, + decimals: 6, + getBalance: async () => { + throw new Error('rpc down'); + }, + }), + }, + ]; + + const pendingTransfersClient: Pick< + ExplorerPendingTransfersClient, + 'getPendingDestinationTransfers' + > = { + async getPendingDestinationTransfers() { + return [] as PendingDestinationTransfer[]; + }, + }; + + await invokeUpdatePendingAndInventoryMetrics( + monitor, + { multiProvider: {} } as WarpCore, + routerNodes, + new Map([[nodeId, 1_000_000n]]), + 'MULTI/inventory-fail-test', + pendingTransfersClient as ExplorerPendingTransfersClient, + 200, + '0x1111111111111111111111111111111111111111', + ); + + const metrics = await metricsRegister.metrics(); + const inventoryLines = metrics + .split('\n') + .filter((line) => + line.startsWith('hyperlane_warp_route_inventory_balance{'), + ); + expect( + inventoryLines.some((line) => line.includes(`node_id="${nodeId}"`)), + ).to.equal(false); + }); + + it('resets pending metrics and still updates inventory when explorer query fails', async () => { + const monitor = new WarpMonitor( + { + warpRouteId: 'MULTI/explorer-fail-test', + checkFrequency: 10_000, + }, + {} as IRegistry, + ); + + const nodeId = 'COLLAT|anvil2|0xroutera'; + const routerNodes: RouterNodeMetadata[] = [ + { + nodeId, + chainName: 'anvil2' as RouterNodeMetadata['chainName'], + domainId: 31337, + routerAddress: '0xroutera', + tokenAddress: '0xtokena', + tokenName: 'Collateral Token', + tokenSymbol: 'COLLAT', + tokenDecimals: 6, + token: createMockToken({ + collateralized: true, + decimals: 6, + getBalance: async () => 1_000_000n, + }), + }, + ]; + + const pendingTransfersClient: Pick< + ExplorerPendingTransfersClient, + 'getPendingDestinationTransfers' + > = { + async getPendingDestinationTransfers() { + throw new Error('explorer down'); + }, + }; + + await invokeUpdatePendingAndInventoryMetrics( + monitor, + { multiProvider: {} } as WarpCore, + routerNodes, + new Map([[nodeId, 2_000_000n]]), + 'MULTI/explorer-fail-test', + pendingTransfersClient as ExplorerPendingTransfersClient, + 200, + '0x1111111111111111111111111111111111111111', + ); + + const metrics = await metricsRegister.metrics(); + const pendingAmountLine = metrics + .split('\n') + .find( + (line) => + line.startsWith('hyperlane_warp_route_pending_destination_amount{') && + line.includes(`node_id="${nodeId}"`), + ); + expect(pendingAmountLine).to.exist; + expect(pendingAmountLine!.trim().endsWith(' 0')).to.equal(true); + + const inventoryLine = metrics + .split('\n') + .find( + (line) => + line.startsWith('hyperlane_warp_route_inventory_balance{') && + line.includes(`node_id="${nodeId}"`), + ); + expect(inventoryLine).to.exist; + expect(inventoryLine!.trim().endsWith(' 1')).to.equal(true); + }); +}); diff --git a/typescript/warp-monitor/src/monitor.ts b/typescript/warp-monitor/src/monitor.ts index 42cf31d836a..f547228253e 100644 --- a/typescript/warp-monitor/src/monitor.ts +++ b/typescript/warp-monitor/src/monitor.ts @@ -1,3 +1,5 @@ +import { utils as ethersUtils } from 'ethers'; + import { type TokenPriceGetter, getExtraLockboxBalance, @@ -28,16 +30,37 @@ import { tryFn, } from '@hyperlane-xyz/utils'; +import { + ExplorerPendingTransfersClient, + type RouterNodeMetadata, +} from './explorer.js'; import { metricsRegister, + resetInventoryBalanceMetrics, + resetPendingDestinationMetrics, + updateInventoryBalanceMetrics, updateManagedLockboxBalanceMetrics, updateNativeWalletBalanceMetrics, + updatePendingDestinationMetrics, + updateProjectedDeficitMetrics, updateTokenBalanceMetrics, updateXERC20LimitsMetrics, } from './metrics.js'; import type { WarpMonitorConfig } from './types.js'; import { getLogger, setLoggerBindings } from './utils.js'; +type RouterCollateralSnapshot = { + nodeId: string; + routerCollateralBaseUnits: bigint; + token: Token; +}; + +type PendingDestinationAggregate = { + amountBaseUnits: bigint; + count: number; + oldestPendingSeconds: number; +}; + export class WarpMonitor { private readonly config: WarpMonitorConfig; private readonly registry: IRegistry; @@ -49,7 +72,14 @@ export class WarpMonitor { async start(): Promise { const logger = getLogger(); - const { warpRouteId, checkFrequency, coingeckoApiKey } = this.config; + const { + warpRouteId, + checkFrequency, + coingeckoApiKey, + explorerApiUrl, + explorerQueryLimit, + inventoryAddress, + } = this.config; setLoggerBindings({ warp_route: warpRouteId, @@ -92,6 +122,10 @@ export class WarpMonitor { const warpCore = WarpCore.FromConfig(multiProtocolProvider, warpCoreConfig); const warpDeployConfig = await this.registry.getWarpDeployConfig(warpRouteId); + const routerNodes = this.buildRouterNodes(warpCore, chainMetadata); + const pendingTransfersClient = explorerApiUrl + ? new ExplorerPendingTransfersClient(explorerApiUrl, routerNodes, logger) + : undefined; logger.info( { @@ -99,6 +133,9 @@ export class WarpMonitor { checkFrequency, tokenCount: warpCore.tokens.length, chains: warpCore.getTokenChains(), + multiCollateralNodeCount: routerNodes.length, + explorerEnabled: !!pendingTransfersClient, + inventoryTrackingEnabled: !!inventoryAddress, }, 'Starting warp route monitor', ); @@ -110,6 +147,10 @@ export class WarpMonitor { chainMetadata, warpRouteId, coingeckoApiKey, + routerNodes, + pendingTransfersClient, + explorerQueryLimit, + inventoryAddress, ); } @@ -120,7 +161,11 @@ export class WarpMonitor { warpDeployConfig: WarpRouteDeployConfig | null, chainMetadata: ChainMap, warpRouteId: string, - coingeckoApiKey?: string, + coingeckoApiKey: string | undefined, + routerNodes: RouterNodeMetadata[], + pendingTransfersClient?: ExplorerPendingTransfersClient, + explorerQueryLimit = 200, + inventoryAddress?: string, ): Promise { const logger = getLogger(); const tokenPriceGetter = new CoinGeckoTokenPriceGetter({ @@ -144,7 +189,7 @@ export class WarpMonitor { while (true) { await tryFn( async () => { - await Promise.all( + const collateralSnapshots = await Promise.all( warpCore.tokens.map((token) => this.updateTokenMetrics( warpCore, @@ -155,6 +200,25 @@ export class WarpMonitor { ), ), ); + + const collateralByNodeId = new Map(); + for (const snapshot of collateralSnapshots) { + if (!snapshot) continue; + collateralByNodeId.set( + snapshot.nodeId, + snapshot.routerCollateralBaseUnits, + ); + } + + await this.updatePendingAndInventoryMetrics( + warpCore, + routerNodes, + collateralByNodeId, + warpRouteId, + pendingTransfersClient, + explorerQueryLimit, + inventoryAddress, + ); }, 'Updating warp route metrics', logger, @@ -163,6 +227,164 @@ export class WarpMonitor { } } + private async updatePendingAndInventoryMetrics( + warpCore: WarpCore, + routerNodes: RouterNodeMetadata[], + collateralByNodeId: Map, + warpRouteId: string, + pendingTransfersClient?: ExplorerPendingTransfersClient, + explorerQueryLimit = 200, + inventoryAddress?: string, + ): Promise { + const logger = getLogger(); + const now = Date.now(); + + resetPendingDestinationMetrics(); + resetInventoryBalanceMetrics(); + + const pendingByNodeId = new Map(); + if (pendingTransfersClient) { + try { + const pendingTransfers = + await pendingTransfersClient.getPendingDestinationTransfers( + explorerQueryLimit, + ); + + for (const transfer of pendingTransfers) { + const aggregate = pendingByNodeId.get(transfer.destinationNodeId) ?? { + amountBaseUnits: 0n, + count: 0, + oldestPendingSeconds: 0, + }; + + aggregate.amountBaseUnits += transfer.amountBaseUnits; + aggregate.count += 1; + + if (transfer.sendOccurredAtMs) { + const ageSeconds = Math.max( + 0, + Math.floor((now - transfer.sendOccurredAtMs) / 1000), + ); + aggregate.oldestPendingSeconds = Math.max( + aggregate.oldestPendingSeconds, + ageSeconds, + ); + } + + pendingByNodeId.set(transfer.destinationNodeId, aggregate); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.error( + { + error: message, + }, + 'Failed to query explorer pending transfers', + ); + } + } + + const deficits: Array<{ nodeId: string; projectedDeficit: string }> = []; + for (const node of routerNodes) { + const aggregate = pendingByNodeId.get(node.nodeId) ?? { + amountBaseUnits: 0n, + count: 0, + oldestPendingSeconds: 0, + }; + + const routerCollateral = collateralByNodeId.get(node.nodeId) ?? 0n; + + updatePendingDestinationMetrics({ + warpRouteId, + nodeId: node.nodeId, + chainName: node.chainName, + routerAddress: node.routerAddress, + tokenAddress: node.tokenAddress, + tokenSymbol: node.tokenSymbol, + tokenName: node.tokenName, + pendingAmount: this.formatTokenAmount( + node.token, + aggregate.amountBaseUnits, + ), + pendingCount: aggregate.count, + oldestPendingSeconds: aggregate.oldestPendingSeconds, + }); + + if (!node.token.isCollateralized()) { + continue; + } + + const projectedDeficitBaseUnits = + aggregate.amountBaseUnits > routerCollateral + ? aggregate.amountBaseUnits - routerCollateral + : 0n; + + updateProjectedDeficitMetrics({ + warpRouteId, + nodeId: node.nodeId, + chainName: node.chainName, + routerAddress: node.routerAddress, + tokenAddress: node.tokenAddress, + tokenSymbol: node.tokenSymbol, + tokenName: node.tokenName, + projectedDeficit: this.formatTokenAmount( + node.token, + projectedDeficitBaseUnits, + ), + }); + + if (projectedDeficitBaseUnits > 0n) { + deficits.push({ + nodeId: node.nodeId, + projectedDeficit: projectedDeficitBaseUnits.toString(), + }); + } + } + + if (deficits.length > 0) { + logger.warn( + { + deficits, + deficitNodeCount: deficits.length, + }, + 'Detected projected destination deficits from pending transfers', + ); + } + + if (!inventoryAddress) return; + + await Promise.all( + routerNodes.map(async (node) => { + try { + const adapter = node.token.getAdapter(warpCore.multiProvider); + const inventoryBalance = await adapter.getBalance(inventoryAddress); + + updateInventoryBalanceMetrics({ + warpRouteId, + nodeId: node.nodeId, + chainName: node.chainName, + routerAddress: node.routerAddress, + tokenAddress: node.tokenAddress, + tokenSymbol: node.tokenSymbol, + tokenName: node.tokenName, + inventoryAddress, + inventoryBalance: this.formatTokenAmount( + node.token, + inventoryBalance, + ), + }); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : String(error); + logger.error( + { nodeId: node.nodeId, error: message }, + `Reading inventory balance for ${node.nodeId} failed`, + ); + } + }), + ); + } + // Updates the metrics for a single token in a warp route. private async updateTokenMetrics( warpCore: WarpCore, @@ -170,21 +392,37 @@ export class WarpMonitor { token: Token, tokenPriceGetter: TokenPriceGetter, warpRouteId: string, - ): Promise { + ): Promise { const logger = getLogger(); + let collateralSnapshot: RouterCollateralSnapshot | null = null; const promises = [ tryFn( async () => { + const bridgedSupply = token.isHypToken() + ? await token + .getHypAdapter(warpCore.multiProvider) + .getBridgedSupply() + : undefined; + const balanceInfo = await getTokenBridgedBalance( warpCore, token, tokenPriceGetter, logger, + bridgedSupply, ); if (!balanceInfo) { return; } updateTokenBalanceMetrics(warpCore, token, balanceInfo, warpRouteId); + + if (bridgedSupply !== undefined) { + collateralSnapshot = { + nodeId: this.buildNodeId(token), + routerCollateralBaseUnits: bridgedSupply, + token, + }; + } }, 'Getting bridged balance and value', logger, @@ -240,7 +478,7 @@ export class WarpMonitor { 'Failed to read warp deploy config, skipping extra lockboxes', ); await Promise.all(promises); - return; + return collateralSnapshot; } // If the current token is an xERC20, we need to check if there are any extra lockboxes @@ -259,7 +497,7 @@ export class WarpMonitor { 'Invalid deploy config type for xERC20 token', ); await Promise.all(promises); - return; + return collateralSnapshot; } const extraLockboxes = @@ -323,6 +561,52 @@ export class WarpMonitor { } await Promise.all(promises); + return collateralSnapshot; + } + + private buildRouterNodes( + warpCore: WarpCore, + chainMetadata: ChainMap, + ): RouterNodeMetadata[] { + const nodeByKey = new Map(); + + for (const token of warpCore.tokens) { + const metadata = chainMetadata[token.chainName]; + if (!metadata) continue; + if (!ethersUtils.isAddress(token.addressOrDenom)) continue; + + const domainId = metadata.domainId; + const routerAddress = ethersUtils + .getAddress(token.addressOrDenom) + .toLowerCase(); + const key = `${domainId}:${routerAddress}`; + if (nodeByKey.has(key)) continue; + + nodeByKey.set(key, { + nodeId: this.buildNodeId(token), + chainName: token.chainName, + domainId, + routerAddress, + tokenAddress: ( + token.collateralAddressOrDenom ?? token.addressOrDenom + ).toLowerCase(), + tokenName: token.name, + tokenSymbol: token.symbol, + tokenDecimals: token.decimals, + tokenScale: token.scale ?? 1, + token, + }); + } + + return [...nodeByKey.values()]; + } + + private buildNodeId(token: Token): string { + return `${token.symbol}|${token.chainName}|${token.addressOrDenom.toLowerCase()}`; + } + + private formatTokenAmount(token: Token, amount: bigint): number { + return token.amount(amount).getDecimalFormattedAmount(); } // Tries to get the price of a token from CoinGecko. Returns undefined if there's no diff --git a/typescript/warp-monitor/src/service.ts b/typescript/warp-monitor/src/service.ts index 03be24557e3..27e6b61eefe 100644 --- a/typescript/warp-monitor/src/service.ts +++ b/typescript/warp-monitor/src/service.ts @@ -13,6 +13,9 @@ * - LOG_LEVEL: Logging level (default: "info") - supported by pino * - REGISTRY_URI: Registry URI for chain metadata. Can include /tree/{commit} to pin version (default: GitHub registry) * - RPC_URL_: Override RPC URL for a specific chain (e.g., RPC_URL_ETHEREUM, RPC_URL_ARBITRUM) + * - EXPLORER_API_URL: Hyperlane explorer GraphQL endpoint for pending transfer liabilities (optional) + * - EXPLORER_QUERY_LIMIT: Max pending transfer rows fetched per cycle (default: 200) + * - INVENTORY_ADDRESS: Address whose per-node inventory balances should be tracked (optional) * * Usage: * node dist/service.js @@ -49,6 +52,18 @@ async function main(): Promise { } const coingeckoApiKey = process.env.COINGECKO_API_KEY; + const explorerApiUrl = process.env.EXPLORER_API_URL; + const inventoryAddress = process.env.INVENTORY_ADDRESS; + + let explorerQueryLimit = 200; + if (process.env.EXPLORER_QUERY_LIMIT) { + const parsed = Number(process.env.EXPLORER_QUERY_LIMIT); + if (!Number.isInteger(parsed) || parsed <= 0) { + rootLogger.error('EXPLORER_QUERY_LIMIT must be a positive integer'); + process.exit(1); + } + explorerQueryLimit = parsed; + } // Create logger (uses LOG_LEVEL environment variable for level configuration) const logger = await initializeLogger('warp-balance-monitor', VERSION); @@ -58,6 +73,9 @@ async function main(): Promise { version: VERSION, warpRouteId, checkFrequency, + explorerApiUrl, + explorerQueryLimit, + inventoryAddress, }, 'Starting Hyperlane Warp Balance Monitor Service', ); @@ -80,6 +98,9 @@ async function main(): Promise { checkFrequency, coingeckoApiKey, registryUri, + explorerApiUrl, + explorerQueryLimit, + inventoryAddress, }, registry, ); diff --git a/typescript/warp-monitor/src/types.test.ts b/typescript/warp-monitor/src/types.test.ts index ff73d4b1f25..cb014246f40 100644 --- a/typescript/warp-monitor/src/types.test.ts +++ b/typescript/warp-monitor/src/types.test.ts @@ -75,6 +75,9 @@ describe('Warp Monitor Types', () => { checkFrequency: 30000, coingeckoApiKey: 'test-api-key', registryUri: 'https://github.com/hyperlane-xyz/hyperlane-registry', + explorerApiUrl: 'https://explorer4.hasura.app/v1/graphql', + explorerQueryLimit: 500, + inventoryAddress: '0x1234567890123456789012345678901234567890', }; expect(config.warpRouteId).to.equal('ETH/ethereum-polygon'); @@ -83,6 +86,13 @@ describe('Warp Monitor Types', () => { expect(config.registryUri).to.equal( 'https://github.com/hyperlane-xyz/hyperlane-registry', ); + expect(config.explorerApiUrl).to.equal( + 'https://explorer4.hasura.app/v1/graphql', + ); + expect(config.explorerQueryLimit).to.equal(500); + expect(config.inventoryAddress).to.equal( + '0x1234567890123456789012345678901234567890', + ); }); it('should allow optional fields', () => { @@ -93,6 +103,9 @@ describe('Warp Monitor Types', () => { expect(config.coingeckoApiKey).to.be.undefined; expect(config.registryUri).to.be.undefined; + expect(config.explorerApiUrl).to.be.undefined; + expect(config.explorerQueryLimit).to.be.undefined; + expect(config.inventoryAddress).to.be.undefined; }); }); }); diff --git a/typescript/warp-monitor/src/types.ts b/typescript/warp-monitor/src/types.ts index 62f47bf06ef..d766c161c0f 100644 --- a/typescript/warp-monitor/src/types.ts +++ b/typescript/warp-monitor/src/types.ts @@ -3,4 +3,7 @@ export interface WarpMonitorConfig { checkFrequency: number; coingeckoApiKey?: string; registryUri?: string; + explorerApiUrl?: string; + explorerQueryLimit?: number; + inventoryAddress?: string; }