From 4b6cb781560b54763f0c47704a5dcb76d3df1a36 Mon Sep 17 00:00:00 2001 From: nambrot Date: Sat, 28 Feb 2026 13:23:04 -0500 Subject: [PATCH 01/26] feat: add multicollateral sdk and cli support --- docs/multi-collateral-design.md | 119 ++++++ typescript/cli/src/commands/warp.ts | 41 +- typescript/cli/src/config/warp.ts | 2 + typescript/cli/src/deploy/warp.ts | 104 ++++- .../cli/src/tests/ethereum/commands/warp.ts | 18 + .../warp/warp-multi-collateral.e2e-test.ts | 380 +++++++++++++++++ .../ethereum/warp/warp-multi-peer.e2e-test.ts | 400 ++++++++++++++++++ typescript/sdk/src/index.ts | 2 + .../src/token/EvmWarpModule.hardhat-test.ts | 6 + typescript/sdk/src/token/EvmWarpModule.ts | 123 ++++++ .../sdk/src/token/EvmWarpRouteReader.ts | 63 +++ typescript/sdk/src/token/IToken.ts | 1 + typescript/sdk/src/token/Token.test.ts | 1 + typescript/sdk/src/token/Token.ts | 10 + typescript/sdk/src/token/TokenStandard.ts | 4 + .../adapters/EvmMultiCollateralAdapter.ts | 265 ++++++++++++ typescript/sdk/src/token/config.ts | 3 + typescript/sdk/src/token/contracts.ts | 3 + typescript/sdk/src/token/deploy.ts | 64 ++- .../sdk/src/token/tokenMetadataUtils.ts | 2 + typescript/sdk/src/token/types.ts | 24 +- typescript/sdk/src/warp/WarpCore.ts | 202 +++++++++ 22 files changed, 1831 insertions(+), 6 deletions(-) create mode 100644 docs/multi-collateral-design.md create mode 100644 typescript/cli/src/tests/ethereum/warp/warp-multi-collateral.e2e-test.ts create mode 100644 typescript/cli/src/tests/ethereum/warp/warp-multi-peer.e2e-test.ts create mode 100644 typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts diff --git a/docs/multi-collateral-design.md b/docs/multi-collateral-design.md new file mode 100644 index 00000000000..4c59373b292 --- /dev/null +++ b/docs/multi-collateral-design.md @@ -0,0 +1,119 @@ +# MultiCollateral Design Plan + +## Context + +Replace sUSD Hub + ICA design (2 messages, non-atomic) with direct peer-to-peer collateral routing (1 message, atomic). Each deployed instance holds collateral for one ERC20. Enrolled routers are other MultiCollateral instances (same or different token). + +**Decisions:** Fees on `localTransferTo` (ITokenFee gets params, decides by domain). Batch-only enrollment. Config like normal warp routes (movable collateral, owners, etc). + +--- + +## Phase 1: Contract (DONE) + +**File:** `solidity/contracts/token/extensions/MultiCollateral.sol` + +**Extends:** `HypERC20Collateral` — inherits rebalancing, LP staking, fees, decimal scaling, `_transferFromSender`/`_transferTo`. + +### Storage + +```solidity +mapping(uint32 domain => mapping(bytes32 router => bool)) public enrolledRouters; +``` + +Single mapping for both cross-chain and local routers. Local routers use `localDomain` as key. + +### Functions + +**Router management (onlyOwner, batch-only):** + +- `enrollRouters(uint32[] domains, bytes32[] routers)` — batch enroll +- `unenrollRouters(uint32[] domains, bytes32[] routers)` — batch unenroll + +**`handle()` override** (overrides `Router.handle`): + +```solidity +function handle( + uint32 _origin, + bytes32 _sender, + bytes calldata _message +) external payable override onlyMailbox { + require( + _isRemoteRouter(_origin, _sender) || enrolledRouters[_origin][_sender], + 'MC: unauthorized router' + ); + _handle(_origin, _sender, _message); +} +``` + +**`transferRemoteTo()`** — cross-chain to specific router: + +- Checks `_isRemoteRouter || enrolledRouters` +- Reuses fee pipeline from TokenRouter +- Dispatches directly to target (bypasses `_Router_dispatch` which hardcodes enrolled router) + +**`localTransferTo()`** — same-chain swap with fees: + +- Checks `enrolledRouters[localDomain]` +- Charges fee via `_feeRecipientAndAmount(localDomain, ...)` +- Calls `MultiCollateral(_targetRouter).receiveLocalSwap(canonical, recipient)` + +**`receiveLocalSwap()`** — called by local enrolled router + +**`quoteTransferRemoteTo()`** — returns 3 quotes: native gas, token+fee, external fee + +### Events + +- `RouterEnrolled(uint32 indexed domain, bytes32 indexed router)` +- `RouterUnenrolled(uint32 indexed domain, bytes32 indexed router)` +- Reuse `SentTransferRemote` from TokenRouter + +--- + +## Phase 2: Forge Tests (DONE) + +**File:** `solidity/test/token/extensions/MultiCollateral.t.sol` + +22 tests covering: cross-chain same-stablecoin, cross-chain cross-stablecoin, same-chain swap, fees, decimal scaling (6↔18), unauthorized reverts, owner-only enrollment, bidirectional transfers, batch enroll/unenroll, events, quoting. + +--- + +## Phase 3: SDK Registration (DONE) + +| File | Change | +| ------------------------------------------------ | --------------------------------------------------------------- | +| `typescript/sdk/src/token/config.ts` | `TokenType.multiCollateral`, movable map entry | +| `typescript/sdk/src/token/types.ts` | `MultiCollateralTokenConfigSchema` with `enrolledRouters` field | +| `typescript/sdk/src/token/contracts.ts` | `MultiCollateral__factory` import/mapping | +| `typescript/sdk/src/token/deploy.ts` | Constructor/init args + batch `enrollRouters()` post-deploy | +| `typescript/sdk/src/token/TokenStandard.ts` | `EvmHypMultiCollateral` enum + mappings | +| `typescript/sdk/src/token/Token.ts` | Adapter mapping → `EvmMovableCollateralAdapter` | +| `typescript/sdk/src/token/tokenMetadataUtils.ts` | `isMultiCollateralTokenConfig` | + +--- + +## Phase 4: CLI E2E Tests (DONE) + +**File:** `typescript/cli/src/tests/ethereum/warp/warp-multi-peer.e2e-test.ts` + +3 tests: + +1. Same-stablecoin round trip via CLI `transferRemote` +2. Cross-stablecoin via `transferRemoteTo` (USDC→USDT, decimal scaling) +3. Same-chain local swap via `localTransferTo` + +Also added `multiCollateral` to `ignoreTokenTypes` in `generateWarpConfigs`. + +--- + +## Verification + +```bash +# Solidity (22/22 pass) +forge test --match-contract MultiCollateralTest -vvv + +# SDK builds (pre-existing type errors only) +pnpm -C typescript/sdk build + +# CLI e2e (slow) +pnpm -C typescript/cli test:ethereum:e2e +``` diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index 6ed94fd56b8..fa83c126d0d 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,40 @@ 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 (defaults to MULTI/route1+route2)', + demandOption: false, + }, + }, + handler: async ({ context, routes, 'output-warp-route-id': outputId }) => { + logCommandHeader('Hyperlane Warp Combine'); + + const routeIds = routes.split(',').map((r) => r.trim()); + await runWarpRouteCombine({ + context, + routeIds, + outputWarpRouteId: outputId, + }); + process.exit(0); + }, +}; + export const init: CommandModuleWithContext<{ advanced: boolean; out: string; diff --git a/typescript/cli/src/config/warp.ts b/typescript/cli/src/config/warp.ts index eb9885c556b..df8869d5ed2 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) diff --git a/typescript/cli/src/deploy/warp.ts b/typescript/cli/src/deploy/warp.ts index 391f70feb9a..3797d86ebbf 100644 --- a/typescript/cli/src/deploy/warp.ts +++ b/typescript/cli/src/deploy/warp.ts @@ -23,6 +23,7 @@ import { ExplorerLicenseType, HypERC20Deployer, IsmType, + type MultiCollateralTokenConfig, type MultiProvider, type MultisigIsmConfig, type OpStackIsmConfig, @@ -47,12 +48,14 @@ import { getSubmitterBuilder, getTokenConnectionId, isCollateralTokenConfig, + isMultiCollateralTokenConfig, isXERC20TokenConfig, splitWarpCoreAndExtendedConfigs, tokenTypeToStandard, } from '@hyperlane-xyz/sdk'; import { type Address, + addressToBytes32, assert, mapAllSettled, mustGet, @@ -385,8 +388,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); @@ -1237,3 +1242,98 @@ export async function getSubmitterByStrategy({ config: submissionStrategy, }; } + +/** + * 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'); + + // 1. Read each route's WarpCoreConfig and deploy config + const routes: Array<{ + id: string; + coreConfig: WarpCoreConfig; + deployConfig: WarpRouteDeployConfigMailboxRequired; + }> = []; + + for (const id of routeIds) { + const coreConfig = await context.registry.getWarpRoute(id); + assert(coreConfig, `Warp route "${id}" not found in registry`); + const deployConfig = await context.registry.getWarpDeployConfig(id); + assert(deployConfig, `Deploy config for "${id}" not found in registry`); + routes.push({ + id, + coreConfig, + deployConfig: deployConfig as WarpRouteDeployConfigMailboxRequired, + }); + } + + // 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)) { + if (!isMultiCollateralTokenConfig(chainConfig)) continue; + + const mcConfig = chainConfig as MultiCollateralTokenConfig; + const enrolledRouters: Record = + mcConfig.enrolledRouters ?? {}; + + // 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(); + const otherRouter = addressToBytes32(otherToken.addressOrDenom!); + + enrolledRouters[otherDomain] ??= []; + if (!enrolledRouters[otherDomain].includes(otherRouter)) { + enrolledRouters[otherDomain].push(otherRouter); + } + } + } + + (route.deployConfig[chain] as any).enrolledRouters = enrolledRouters; + } + + // 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: [] }; + + for (const route of routes) { + for (const token of route.coreConfig.tokens) { + mergedConfig.tokens.push({ ...token, connections: [] }); + } + } + + // Full mesh connections (every token → every other token) + fullyConnectTokens(mergedConfig, context.multiProvider); + + // 4. Write merged WarpCoreConfig + const mergedId = + outputWarpRouteId ?? + `MULTI/${routes.map((r) => r.id.replace(/\//g, '-')).join('+')}`; + 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/tests/ethereum/commands/warp.ts b/typescript/cli/src/tests/ethereum/commands/warp.ts index 48ea86d1712..8279ee36be7 100644 --- a/typescript/cli/src/tests/ethereum/commands/warp.ts +++ b/typescript/cli/src/tests/ethereum/commands/warp.ts @@ -272,6 +272,22 @@ export function hyperlaneWarpSendRelay({ ${roundTrip ? ['--round-trip'] : []} `; } +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( checkFrequency: number, config: string, @@ -448,6 +464,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..8602a2603c4 --- /dev/null +++ b/typescript/cli/src/tests/ethereum/warp/warp-multi-collateral.e2e-test.ts @@ -0,0 +1,380 @@ +import { JsonRpcProvider } from '@ethersproject/providers'; +import { expect } from 'chai'; +import { Wallet, ethers } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils.js'; + +import { MultiCollateral__factory } from '@hyperlane-xyz/core'; +import { + type ChainAddresses, + createWarpRouteConfigId, +} from '@hyperlane-xyz/registry'; +import { + type ChainMap, + type ChainMetadata, + type Token, + TokenStandard, + TokenType, + type WarpCoreConfig, + type WarpRouteDeployConfig, +} from '@hyperlane-xyz/sdk'; +import { type Address, addressToBytes32 } from '@hyperlane-xyz/utils'; + +import { readYamlOrJson, writeYamlOrJson } from '../../../utils/files.js'; +import { deployOrUseExistingCore } from '../commands/core.js'; +import { deployToken, getDomainId } from '../commands/helpers.js'; +import { + hyperlaneWarpDeploy, + hyperlaneWarpSendRelay, +} from '../commands/warp.js'; +import { + ANVIL_KEY, + CHAIN_2_METADATA_PATH, + CHAIN_3_METADATA_PATH, + CHAIN_NAME_2, + CHAIN_NAME_3, + CORE_CONFIG_PATH, + TEMP_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 = {}; + + let ownerAddress: Address; + let walletChain2: Wallet; + let walletChain3: Wallet; + + let chain2DomainId: number; + let chain3DomainId: number; + + 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), + ]); + + const chain2Metadata: ChainMetadata = readYamlOrJson(CHAIN_2_METADATA_PATH); + const chain3Metadata: ChainMetadata = readYamlOrJson(CHAIN_3_METADATA_PATH); + + const providerChain2 = new JsonRpcProvider(chain2Metadata.rpcUrls[0].http); + const providerChain3 = new JsonRpcProvider(chain3Metadata.rpcUrls[0].http); + + walletChain2 = new Wallet(ANVIL_KEY).connect(providerChain2); + walletChain3 = new Wallet(ANVIL_KEY).connect(providerChain3); + ownerAddress = walletChain2.address; + + chain2DomainId = Number(await getDomainId(CHAIN_NAME_2, ANVIL_KEY)); + chain3DomainId = Number(await getDomainId(CHAIN_NAME_3, 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); + + // Read deployed configs + 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; + + // Enroll cross-stablecoin routers (bidirectional) + const usdcRouter2 = MultiCollateral__factory.connect( + usdcRouter2Addr, + walletChain2, + ); + const usdtRouter3 = MultiCollateral__factory.connect( + usdtRouter3Addr, + walletChain3, + ); + + await ( + await usdcRouter2.enrollRouters( + [chain3DomainId], + [addressToBytes32(usdtRouter3Addr)], + ) + ).wait(); + await ( + await usdtRouter3.enrollRouters( + [chain2DomainId], + [addressToBytes32(usdcRouter2Addr)], + ) + ).wait(); + + // Collateralize USDT router on chain 3 + const usdtCollateral = parseUnits('10', 18); + await (await usdtChain3.transfer(usdtRouter3Addr, usdtCollateral)).wait(); + + // Build merged WarpCoreConfig for CLI warp send + const usdcCoreConfig = readYamlOrJson( + USDC_WARP_CONFIG_PATH, + ) as WarpCoreConfig; + const usdtCoreConfig = readYamlOrJson( + USDT_WARP_CONFIG_PATH, + ) as WarpCoreConfig; + + // Find the specific tokens + const usdcToken2 = usdcCoreConfig.tokens.find( + (t) => t.chainName === CHAIN_NAME_2, + )!; + const usdtToken3 = usdtCoreConfig.tokens.find( + (t) => t.chainName === CHAIN_NAME_3, + )!; + + // Create merged config with cross-stablecoin connections + const mergedConfig: WarpCoreConfig = { + tokens: [ + { + ...usdcToken2, + connections: [ + { + token: `ethereum|${CHAIN_NAME_3}|${usdtRouter3Addr}`, + }, + ], + }, + { + ...usdtToken3, + connections: [ + { + token: `ethereum|${CHAIN_NAME_2}|${usdcRouter2Addr}`, + }, + ], + }, + ], + }; + + const MERGED_CONFIG_PATH = `${TEMP_PATH}/multi-collateral-merged-config.yaml`; + writeYamlOrJson(MERGED_CONFIG_PATH, mergedConfig); + + // Send cross-stablecoin transfer via CLI: USDC(chain2) -> USDT(chain3) + const sendAmount = parseUnits('1', 6); // 1 USDC in 6-dec + 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 recipientBalance = await usdtChain3.balanceOf(ownerAddress); + // The balance should include the received 1 USDT (1e18) + expect(recipientBalance.gte(parseUnits('1', 18))).to.be.true; + }); + + 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 MultiCollateral routers programmatically + const usdcScale = ethers.BigNumber.from(10).pow(12); + const usdtScale = ethers.BigNumber.from(1); + + const usdcRouter = await new MultiCollateral__factory(walletChain2).deploy( + usdc.address, + usdcScale, + chain2Addresses.mailbox, + ); + await usdcRouter.deployed(); + await ( + await usdcRouter.initialize( + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ownerAddress, + ) + ).wait(); + + const usdtRouter = await new MultiCollateral__factory(walletChain2).deploy( + usdt.address, + usdtScale, + chain2Addresses.mailbox, + ); + await usdtRouter.deployed(); + await ( + await usdtRouter.initialize( + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ownerAddress, + ) + ).wait(); + + // Enroll bidirectionally (same domain) + const usdcRouterBytes32 = addressToBytes32(usdcRouter.address); + const usdtRouterBytes32 = addressToBytes32(usdtRouter.address); + + await ( + await usdcRouter.enrollRouters([chain2DomainId], [usdtRouterBytes32]) + ).wait(); + await ( + await usdtRouter.enrollRouters([chain2DomainId], [usdcRouterBytes32]) + ).wait(); + + // Collateralize USDT router + const usdtCollateral = parseUnits('10', 18); + await (await usdt.transfer(usdtRouter.address, usdtCollateral)).wait(); + + // Build WarpCoreConfig for CLI + const mergedConfig: WarpCoreConfig = { + tokens: [ + { + chainName: CHAIN_NAME_2, + standard: TokenStandard.EvmHypMultiCollateral, + decimals: 6, + symbol: 'LUSDC2', + name: 'Local USDC 2', + addressOrDenom: usdcRouter.address, + collateralAddressOrDenom: usdc.address, + connections: [ + { + token: `ethereum|${CHAIN_NAME_2}|${usdtRouter.address}`, + }, + ], + }, + { + chainName: CHAIN_NAME_2, + standard: TokenStandard.EvmHypMultiCollateral, + decimals: 18, + symbol: 'LUSDT2', + name: 'Local USDT 2', + addressOrDenom: usdtRouter.address, + collateralAddressOrDenom: usdt.address, + connections: [ + { + token: `ethereum|${CHAIN_NAME_2}|${usdcRouter.address}`, + }, + ], + }, + ], + }; + + const MERGED_CONFIG_PATH = `${TEMP_PATH}/multi-collateral-local-merged.yaml`; + writeYamlOrJson(MERGED_CONFIG_PATH, mergedConfig); + + // Send same-chain swap via CLI: USDC -> USDT on chain 2 + // CLI defaults recipient to signer (ownerAddress) + 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: usdcRouter.address, + destinationToken: usdtRouter.address, + value: swapAmount.toString(), + relay: false, // No relay needed for same-chain + 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/cli/src/tests/ethereum/warp/warp-multi-peer.e2e-test.ts b/typescript/cli/src/tests/ethereum/warp/warp-multi-peer.e2e-test.ts new file mode 100644 index 00000000000..c489556a22a --- /dev/null +++ b/typescript/cli/src/tests/ethereum/warp/warp-multi-peer.e2e-test.ts @@ -0,0 +1,400 @@ +import { JsonRpcProvider } from '@ethersproject/providers'; +import { expect } from 'chai'; +import { Wallet, ethers } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils.js'; + +import { MultiCollateral__factory } from '@hyperlane-xyz/core'; +import { + type ChainAddresses, + createWarpRouteConfigId, +} from '@hyperlane-xyz/registry'; +import { + type ChainMap, + type ChainMetadata, + type Token, + TokenType, + type WarpCoreConfig, + type WarpRouteDeployConfig, +} from '@hyperlane-xyz/sdk'; +import { type Address, addressToBytes32 } from '@hyperlane-xyz/utils'; + +import { readYamlOrJson, writeYamlOrJson } from '../../../utils/files.js'; +import { deployOrUseExistingCore } from '../commands/core.js'; +import { + deployToken, + getDomainId, + hyperlaneStatus, +} from '../commands/helpers.js'; +import { + hyperlaneWarpDeploy, + sendWarpRouteMessageRoundTrip, +} from '../commands/warp.js'; +import { + ANVIL_KEY, + CHAIN_2_METADATA_PATH, + CHAIN_3_METADATA_PATH, + CHAIN_NAME_2, + CHAIN_NAME_3, + CORE_CONFIG_PATH, + WARP_DEPLOY_OUTPUT_PATH, + getCombinedWarpRoutePath, +} from '../consts.js'; + +describe('hyperlane warp multiCollateral e2e tests', async function () { + this.timeout(200_000); + + let chain2Addresses: ChainAddresses = {}; + let chain3Addresses: ChainAddresses = {}; + + let ownerAddress: Address; + let walletChain2: Wallet; + let walletChain3: Wallet; + + 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), + ]); + + const chain2Metadata: ChainMetadata = readYamlOrJson(CHAIN_2_METADATA_PATH); + const chain3Metadata: ChainMetadata = readYamlOrJson(CHAIN_3_METADATA_PATH); + + const providerChain2 = new JsonRpcProvider(chain2Metadata.rpcUrls[0].http); + const providerChain3 = new JsonRpcProvider(chain3Metadata.rpcUrls[0].http); + + walletChain2 = new Wallet(ANVIL_KEY).connect(providerChain2); + walletChain3 = new Wallet(ANVIL_KEY).connect(providerChain3); + ownerAddress = walletChain2.address; + }); + + it('should bridge same-stablecoin round trip (multiCollateral <-> multiCollateral)', async function () { + // Deploy same token on both chains + const tokenChain2 = await deployToken( + ANVIL_KEY, + CHAIN_NAME_2, + 6, + 'USDC', + 'USD Coin', + ); + const tokenChain3 = await deployToken( + ANVIL_KEY, + CHAIN_NAME_3, + 6, + 'USDC', + 'USD Coin', + ); + const tokenSymbol = await tokenChain2.symbol(); + + const WARP_CORE_CONFIG_PATH = getCombinedWarpRoutePath(tokenSymbol, [ + CHAIN_NAME_2, + CHAIN_NAME_3, + ]); + const warpId = createWarpRouteConfigId( + tokenSymbol, + `${CHAIN_NAME_2}-${CHAIN_NAME_3}`, + ); + + // Deploy config: multiCollateral on both chains + const warpConfig: WarpRouteDeployConfig = { + [CHAIN_NAME_2]: { + type: TokenType.multiCollateral, + token: tokenChain2.address, + mailbox: chain2Addresses.mailbox, + owner: ownerAddress, + }, + [CHAIN_NAME_3]: { + type: TokenType.multiCollateral, + token: tokenChain3.address, + mailbox: chain3Addresses.mailbox, + owner: ownerAddress, + }, + }; + + writeYamlOrJson(WARP_DEPLOY_OUTPUT_PATH, warpConfig); + await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, warpId); + + // Read deployed config to get router addresses + const config: ChainMap = ( + readYamlOrJson(WARP_CORE_CONFIG_PATH) as WarpCoreConfig + ).tokens.reduce((acc, curr) => ({ ...acc, [curr.chainName]: curr }), {}); + + // Collateralize both routers + const decimals = await tokenChain2.decimals(); + const collateralAmount = parseUnits('2', decimals); + + await ( + await tokenChain2.transfer( + config[CHAIN_NAME_2].addressOrDenom, + collateralAmount, + ) + ).wait(); + await ( + await tokenChain3.transfer( + config[CHAIN_NAME_3].addressOrDenom, + collateralAmount, + ) + ).wait(); + + // Send round trip via standard transferRemote (same stablecoin = enrolled routers) + await sendWarpRouteMessageRoundTrip( + CHAIN_NAME_2, + CHAIN_NAME_3, + WARP_CORE_CONFIG_PATH, + ); + }); + + it('should bridge cross-stablecoin via transferRemoteTo (USDC -> USDT)', async function () { + // Deploy USDC(6dec) on both chains and USDT(18dec) on both chains + // Deploy sequentially per chain to avoid nonce collisions + const usdcChain2 = await deployToken( + ANVIL_KEY, + CHAIN_NAME_2, + 6, + 'MUSDC', + 'Mock USDC', + ); + const usdtChain2 = await deployToken( + ANVIL_KEY, + CHAIN_NAME_2, + 18, + 'MUSDT', + 'Mock USDT', + ); + const usdcChain3 = await deployToken( + ANVIL_KEY, + CHAIN_NAME_3, + 6, + 'MUSDC', + 'Mock USDC', + ); + const usdtChain3 = await deployToken( + ANVIL_KEY, + CHAIN_NAME_3, + 18, + 'MUSDT', + 'Mock USDT', + ); + + // Deploy USDC warp route (multiCollateral <-> multiCollateral) + const usdcSymbol = await usdcChain2.symbol(); + const USDC_WARP_CONFIG_PATH = getCombinedWarpRoutePath(usdcSymbol, [ + CHAIN_NAME_2, + CHAIN_NAME_3, + ]); + const usdcWarpId = createWarpRouteConfigId( + usdcSymbol, + `${CHAIN_NAME_2}-${CHAIN_NAME_3}`, + ); + + // USDC = 6 decimals, scale = 1e12 to normalize to 18-dec canonical + const usdcScale = 1e12; + 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, + }, + }); + await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, usdcWarpId); + + // Deploy USDT warp route (multiCollateral <-> multiCollateral) + const usdtSymbol = await usdtChain2.symbol(); + const USDT_WARP_CONFIG_PATH = getCombinedWarpRoutePath(usdtSymbol, [ + CHAIN_NAME_2, + CHAIN_NAME_3, + ]); + const usdtWarpId = createWarpRouteConfigId( + usdtSymbol, + `${CHAIN_NAME_2}-${CHAIN_NAME_3}`, + ); + + // USDT = 18 decimals, scale = 1 (default, canonical = local) + 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, + }, + }); + await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, usdtWarpId); + + // Read deployed configs to get router addresses + 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; + + // Connect to routers + const usdcRouter2 = MultiCollateral__factory.connect( + usdcRouter2Addr, + walletChain2, + ); + const usdtRouter3 = MultiCollateral__factory.connect( + usdtRouter3Addr, + walletChain3, + ); + + // Get domain IDs + const chain3DomainId = Number(await getDomainId(CHAIN_NAME_3, ANVIL_KEY)); + const chain2DomainId = Number(await getDomainId(CHAIN_NAME_2, ANVIL_KEY)); + + // Enroll cross-stablecoin routers (bidirectional) + const usdcRouter2Bytes32 = addressToBytes32(usdcRouter2Addr); + const usdtRouter3Bytes32 = addressToBytes32(usdtRouter3Addr); + + await ( + await usdcRouter2.enrollRouters([chain3DomainId], [usdtRouter3Bytes32]) + ).wait(); + await ( + await usdtRouter3.enrollRouters([chain2DomainId], [usdcRouter2Bytes32]) + ).wait(); + + // Collateralize USDT router on chain 3 + const usdtCollateral = parseUnits('10', 18); + await (await usdtChain3.transfer(usdtRouter3Addr, usdtCollateral)).wait(); + + // Approve USDC for usdcRouter2 + const sendAmount = parseUnits('1', 6); // 1 USDC + await (await usdcChain2.approve(usdcRouter2Addr, sendAmount)).wait(); + + // Send cross-stablecoin transfer via transferRemoteTo + const recipientAddr = '0x0000000000000000000000000000000000000042'; + const recipientBytes32 = addressToBytes32(recipientAddr); + + // Quote gas payment for protocol fee + const gasPayment = await usdcRouter2.quoteGasPayment(chain3DomainId); + + const tx = await usdcRouter2.transferRemoteTo( + chain3DomainId, + recipientBytes32, + sendAmount, + usdtRouter3Bytes32, + { value: gasPayment }, + ); + const receipt = await tx.wait(); + + // Relay the message + await hyperlaneStatus({ + origin: CHAIN_NAME_2, + dispatchTx: receipt.transactionHash, + relay: true, + key: ANVIL_KEY, + }); + + // Verify: 1 USDC(6dec) → canonical 1e18 → 1 USDT(18dec) = 1e18 + const recipientBalance = await usdtChain3.balanceOf(recipientAddr); + expect(recipientBalance.toString()).to.equal( + parseUnits('1', 18).toString(), + ); + }); + + it('should swap same-chain via localTransferTo (USDC -> USDT)', async function () { + // Deploy USDC(6dec) and USDT(18dec) on chain 2 + const usdc = await deployToken( + ANVIL_KEY, + CHAIN_NAME_2, + 6, + 'LUSDC', + 'Local USDC', + ); + const usdt = await deployToken( + ANVIL_KEY, + CHAIN_NAME_2, + 18, + 'LUSDT', + 'Local USDT', + ); + + // Deploy MultiCollateral routers programmatically + const usdcScale = ethers.BigNumber.from(10).pow(12); // 6→18 dec scaling + const usdtScale = ethers.BigNumber.from(1); // 18→18 dec (no scaling) + + const usdcRouter = await new MultiCollateral__factory(walletChain2).deploy( + usdc.address, + usdcScale, + chain2Addresses.mailbox, + ); + await usdcRouter.deployed(); + await ( + await usdcRouter.initialize( + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ownerAddress, + ) + ).wait(); + + const usdtRouter = await new MultiCollateral__factory(walletChain2).deploy( + usdt.address, + usdtScale, + chain2Addresses.mailbox, + ); + await usdtRouter.deployed(); + await ( + await usdtRouter.initialize( + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ownerAddress, + ) + ).wait(); + + // Get local domain ID for router enrollment + const localDomainId = Number(await getDomainId(CHAIN_NAME_2, ANVIL_KEY)); + + // Enroll as local routers (bidirectional) + const usdcRouterBytes32 = addressToBytes32(usdcRouter.address); + const usdtRouterBytes32 = addressToBytes32(usdtRouter.address); + + await ( + await usdcRouter.enrollRouters([localDomainId], [usdtRouterBytes32]) + ).wait(); + await ( + await usdtRouter.enrollRouters([localDomainId], [usdcRouterBytes32]) + ).wait(); + + // Collateralize USDT router + const usdtCollateral = parseUnits('10', 18); + await (await usdt.transfer(usdtRouter.address, usdtCollateral)).wait(); + + // Approve USDC for usdcRouter + const swapAmount = parseUnits('1', 6); // 1 USDC + await (await usdc.approve(usdcRouter.address, swapAmount)).wait(); + + // Execute same-chain swap + const recipientAddr = '0x0000000000000000000000000000000000000043'; + const balanceBefore = await usdt.balanceOf(recipientAddr); + + await ( + await usdcRouter.localTransferTo( + usdtRouter.address, + recipientAddr, + swapAmount, + ) + ).wait(); + + // Verify: 1 USDC(6dec) → canonical 1e18 → 1 USDT(18dec) = 1e18 + const balanceAfter = await usdt.balanceOf(recipientAddr); + const received = balanceAfter.sub(balanceBefore); + expect(received.toString()).to.equal(parseUnits('1', 18).toString()); + }); +}); 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..ee67f4fbd7e 100644 --- a/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts +++ b/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts @@ -213,6 +213,12 @@ describe('EvmWarpModule', async () => { type: TokenType.nativeScaled, allowedRebalancers, }, + [TokenType.multiCollateral]: { + ...baseConfig, + type: TokenType.multiCollateral, + token: token.address, + allowedRebalancers, + }, }; }; diff --git a/typescript/sdk/src/token/EvmWarpModule.ts b/typescript/sdk/src/token/EvmWarpModule.ts index fe4ba4f9e21..4646a003b36 100644 --- a/typescript/sdk/src/token/EvmWarpModule.ts +++ b/typescript/sdk/src/token/EvmWarpModule.ts @@ -9,6 +9,7 @@ import { IERC20__factory, MailboxClient__factory, MovableCollateralRouter__factory, + MultiCollateral__factory, ProxyAdmin__factory, TokenRouter__factory, } from '@hyperlane-xyz/core'; @@ -79,6 +80,7 @@ import { derivedIsmAddress, isEverclearTokenBridgeConfig, isMovableCollateralTokenConfig, + isMultiCollateralTokenConfig, isXERC20TokenConfig, } from './types.js'; @@ -211,6 +213,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 +422,104 @@ 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 = actualConfig.enrolledRouters ?? {}; + const expectedEnrolled = expectedConfig.enrolledRouters; + + const domainsToEnroll: number[] = []; + const routersToEnroll: string[] = []; + + for (const [domain, expectedRouters] of Object.entries(expectedEnrolled)) { + const actualRouters = new Set(actualEnrolled[domain] ?? []); + for (const router of expectedRouters) { + if (!actualRouters.has(router)) { + domainsToEnroll.push(Number(domain)); + routersToEnroll.push(router); + } + } + } + + 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 = actualConfig.enrolledRouters ?? {}; + const expectedEnrolled = expectedConfig.enrolledRouters ?? {}; + + const domainsToUnenroll: number[] = []; + const routersToUnenroll: string[] = []; + + for (const [domain, actualRouters] of Object.entries(actualEnrolled)) { + const expectedRouters = new Set(expectedEnrolled[domain] ?? []); + for (const router of actualRouters) { + if (!expectedRouters.has(router)) { + domainsToUnenroll.push(Number(domain)); + routersToUnenroll.push(router); + } + } + } + + 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], + ), + }, + ]; + } + async getAllowedBridgesApprovalTxs( actualConfig: DerivedTokenRouterConfig, expectedConfig: HypTokenRouterConfig, @@ -1371,6 +1479,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.ts b/typescript/sdk/src/token/EvmWarpRouteReader.ts index e5118e23fd3..360b61fb005 100644 --- a/typescript/sdk/src/token/EvmWarpRouteReader.ts +++ b/typescript/sdk/src/token/EvmWarpRouteReader.ts @@ -17,6 +17,7 @@ import { IWETH__factory, IXERC20__factory, MovableCollateralRouter__factory, + MultiCollateral__factory, OpL1NativeTokenBridge__factory, OpL2NativeTokenBridge__factory, Ownable__factory, @@ -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,51 @@ 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] = + await Promise.all([ + mc.wrappedToken(), + tokenRouter.domains(), + mc.localDomain(), + ]); + + 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, + 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..2b27bced326 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,11 @@ export class Token implements IToken { return new EvmMovableCollateralAdapter(chainName, multiProvider, { token: addressOrDenom, }); + } else if (standard === TokenStandard.EvmHypMultiCollateral) { + return new EvmHypMultiCollateralAdapter(chainName, multiProvider, { + token: addressOrDenom, + collateralToken: collateralAddressOrDenom ?? addressOrDenom, + }); } else if (standard === TokenStandard.EvmHypRebaseCollateral) { return new EvmHypRebaseCollateralAdapter(chainName, multiProvider, { token: addressOrDenom, @@ -502,6 +508,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..0e1c69c5c9c 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, @@ -211,6 +213,7 @@ export const TOKEN_HYP_STANDARDS = [ TokenStandard.EvmHypVSXERC20, TokenStandard.EvmHypVSXERC20Lockbox, TokenStandard.EvmM0PortalLite, + TokenStandard.EvmHypMultiCollateral, TokenStandard.SealevelHypNative, TokenStandard.SealevelHypCollateral, TokenStandard.SealevelHypSynthetic, @@ -344,6 +347,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.ts b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts new file mode 100644 index 00000000000..e0f647a0a32 --- /dev/null +++ b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts @@ -0,0 +1,265 @@ +import { PopulatedTransaction } from 'ethers'; + +import { + ERC20__factory, + MultiCollateral, + MultiCollateral__factory, +} from '@hyperlane-xyz/core'; +import { + Address, + Domain, + Numberish, + addressToBytes32, +} from '@hyperlane-xyz/utils'; + +import { BaseEvmAdapter } from '../../app/MultiProtocolApp.js'; +import { MultiProtocolProvider } from '../../providers/MultiProtocolProvider.js'; +import { ChainName } from '../../types.js'; +import { TokenMetadata } from '../types.js'; + +import { + IHypTokenAdapter, + InterchainGasQuote, + QuoteTransferRemoteParams, + TransferParams, + TransferRemoteParams, +} from './ITokenAdapter.js'; + +/** + * Adapter for MultiCollateral routers. + * Supports transferRemoteTo (cross-chain to specific router) and + * localTransferTo (same-chain swap). + */ +export class EvmHypMultiCollateralAdapter + extends BaseEvmAdapter + implements IHypTokenAdapter +{ + public readonly contract: MultiCollateral; + public readonly collateralToken: Address; + + constructor( + public readonly chainName: ChainName, + public readonly multiProvider: MultiProtocolProvider, + public readonly addresses: { + token: Address; // router address + collateralToken: Address; // underlying ERC20 + }, + ) { + super(chainName, multiProvider, addresses); + this.contract = MultiCollateral__factory.connect( + addresses.token, + this.getProvider(), + ); + this.collateralToken = addresses.collateralToken; + } + + async getBalance(address: Address): Promise { + const erc20 = ERC20__factory.connect( + this.collateralToken, + this.getProvider(), + ); + const balance = await erc20.balanceOf(address); + return BigInt(balance.toString()); + } + + async getMetadata(): Promise { + const erc20 = ERC20__factory.connect( + this.collateralToken, + this.getProvider(), + ); + const [decimals, symbol, name] = await Promise.all([ + erc20.decimals(), + erc20.symbol(), + erc20.name(), + ]); + return { decimals, symbol, name }; + } + + async getMinimumTransferAmount(_recipient: Address): Promise { + return 0n; + } + + async getTotalSupply(): Promise { + const erc20 = ERC20__factory.connect( + this.collateralToken, + this.getProvider(), + ); + const totalSupply = await erc20.totalSupply(); + return BigInt(totalSupply.toString()); + } + + async isApproveRequired( + owner: Address, + _spender: Address, + weiAmountOrId: Numberish, + ): Promise { + const erc20 = ERC20__factory.connect( + this.collateralToken, + this.getProvider(), + ); + const allowance = await erc20.allowance(owner, this.addresses.token); + return allowance.lt(weiAmountOrId); + } + + async isRevokeApprovalRequired( + owner: Address, + _spender: Address, + ): Promise { + const erc20 = ERC20__factory.connect( + this.collateralToken, + this.getProvider(), + ); + const allowance = await erc20.allowance(owner, this.addresses.token); + return !allowance.isZero(); + } + + async populateApproveTx({ + weiAmountOrId, + }: TransferParams): Promise { + const erc20 = ERC20__factory.connect( + this.collateralToken, + this.getProvider(), + ); + return erc20.populateTransaction.approve( + this.addresses.token, + weiAmountOrId.toString(), + ); + } + + async populateTransferTx({ + weiAmountOrId, + recipient, + }: TransferParams): Promise { + const erc20 = ERC20__factory.connect( + this.collateralToken, + this.getProvider(), + ); + return erc20.populateTransaction.transfer( + recipient, + weiAmountOrId.toString(), + ); + } + + async getDomains(): Promise { + const domains = await this.contract.domains(); + return domains.map(Number); + } + + async getRouterAddress(domain: Domain): Promise { + const router = await this.contract.routers(domain); + return Buffer.from(router.slice(2), 'hex'); + } + + async getAllRouters(): Promise> { + const domains = await this.getDomains(); + return Promise.all( + domains.map(async (domain) => ({ + domain, + address: await this.getRouterAddress(domain), + })), + ); + } + + async getBridgedSupply(): Promise { + const erc20 = ERC20__factory.connect( + this.collateralToken, + this.getProvider(), + ); + const balance = await erc20.balanceOf(this.addresses.token); + return BigInt(balance.toString()); + } + + // Standard transferRemote (same-stablecoin, uses enrolled remote router) + async populateTransferRemoteTx( + params: TransferRemoteParams, + ): Promise { + const recipientBytes32 = addressToBytes32(params.recipient); + const gasPayment = await this.contract.quoteGasPayment(params.destination); + return this.contract.populateTransaction.transferRemote( + params.destination, + recipientBytes32, + params.weiAmountOrId.toString(), + { value: gasPayment }, + ); + } + + async quoteTransferRemoteGas( + params: QuoteTransferRemoteParams, + ): Promise { + const gasPayment = await this.contract.quoteGasPayment(params.destination); + return { + igpQuote: { amount: BigInt(gasPayment.toString()) }, + }; + } + + // ============ MultiCollateral-specific methods ============ + + /** + * Populate cross-chain transfer to a specific target router. + */ + async populateTransferRemoteToTx(params: { + destination: Domain; + recipient: Address; + amount: Numberish; + targetRouter: Address; + }): Promise { + const recipientBytes32 = addressToBytes32(params.recipient); + const targetRouterBytes32 = addressToBytes32(params.targetRouter); + + // Quote gas + const quotes = await this.contract.quoteTransferRemoteTo( + params.destination, + recipientBytes32, + params.amount.toString(), + targetRouterBytes32, + ); + const nativeGas = quotes[0].amount; + + return this.contract.populateTransaction.transferRemoteTo( + params.destination, + recipientBytes32, + params.amount.toString(), + targetRouterBytes32, + { value: nativeGas }, + ); + } + + /** + * Populate same-chain local transfer to an enrolled router. + */ + async populateLocalTransferToTx(params: { + targetRouter: Address; + recipient: Address; + amount: Numberish; + }): Promise { + return this.contract.populateTransaction.localTransferTo( + params.targetRouter, + params.recipient, + params.amount.toString(), + ); + } + + /** + * Quote fees for transferRemoteTo. + */ + async quoteTransferRemoteToGas(params: { + destination: Domain; + recipient: Address; + amount: Numberish; + targetRouter: Address; + }): Promise { + const recipientBytes32 = addressToBytes32(params.recipient); + const targetRouterBytes32 = addressToBytes32(params.targetRouter); + + const quotes = await this.contract.quoteTransferRemoteTo( + params.destination, + recipientBytes32, + params.amount.toString(), + targetRouterBytes32, + ); + + return { + igpQuote: { amount: BigInt(quotes[0].amount.toString()) }, + }; + } +} 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..5d01d516bbc 100644 --- a/typescript/sdk/src/token/contracts.ts +++ b/typescript/sdk/src/token/contracts.ts @@ -16,6 +16,7 @@ import { HypNative__factory, HypXERC20Lockbox__factory, HypXERC20__factory, + MultiCollateral__factory, OpL1V1NativeTokenBridge__factory, OpL2NativeTokenBridge__factory, TokenBridgeCctpV1__factory, @@ -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..eab6bed2880 100644 --- a/typescript/sdk/src/token/deploy.ts +++ b/typescript/sdk/src/token/deploy.ts @@ -7,6 +7,7 @@ import { GasRouter, IMessageTransmitter__factory, MovableCollateralRouter__factory, + MultiCollateral__factory, OpL1V1NativeTokenBridge__factory, OpL2NativeTokenBridge__factory, PackageVersioned__factory, @@ -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; 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..78deb3ab53d 100644 --- a/typescript/sdk/src/token/types.ts +++ b/typescript/sdk/src/token/types.ts @@ -253,6 +253,25 @@ 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(z.string(), 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 +359,7 @@ const AllHypTokenConfigSchema = z.discriminatedUnion('type', [ CctpTokenConfigSchema, EverclearCollateralTokenConfigSchema, EverclearEthBridgeTokenConfigSchema, + MultiCollateralTokenConfigSchema, UnknownTokenConfigSchema, ]); @@ -445,7 +465,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 +699,7 @@ function extractCCIPIsmMap( const MovableTokenSchema = z.discriminatedUnion('type', [ CollateralTokenConfigSchema, + MultiCollateralTokenConfigSchema, NativeTokenConfigSchema, ]); export type MovableTokenConfig = z.infer; diff --git a/typescript/sdk/src/warp/WarpCore.ts b/typescript/sdk/src/warp/WarpCore.ts index 3f52b3cc319..eae8ea972fa 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, @@ -383,6 +384,21 @@ export class WarpCore { interchainFee?: TokenAmount; tokenFeeQuote?: TokenAmount; }): 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; @@ -520,6 +536,119 @@ 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, + ): boolean { + if (!destinationToken) return false; + return ( + originToken.isMultiCollateralToken() && + destinationToken.isMultiCollateralToken() + ); + } + + /** + * Executes a MultiCollateral transfer between different collateral routers. + * + * For cross-chain: calls transferRemoteTo with the destination router address. + * For same-chain: calls localTransferTo with the destination router address. + */ + 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); + + assert( + originToken.collateralAddressOrDenom, + 'Origin token missing collateralAddressOrDenom', + ); + assert( + destinationToken.addressOrDenom, + 'Destination token missing addressOrDenom', + ); + + const providerType = TOKEN_STANDARD_TO_PROVIDER_TYPE[originToken.standard]; + + const adapter = originToken.getHypAdapter( + this.multiProvider, + destinationName, + ) as EvmHypMultiCollateralAdapter; + + // Check approval + const isApproveRequired = await adapter.isApproveRequired( + sender, + originToken.addressOrDenom!, + amount, + ); + + if (isApproveRequired) { + const approveTxReq = await adapter.populateApproveTx({ + weiAmountOrId: amount, + recipient: originToken.addressOrDenom!, + }); + transactions.push({ + category: WarpTxCategory.Approval, + type: providerType, + transaction: approveTxReq, + } as WarpTypedTransaction); + } + + const isSameChain = originToken.chainName === destinationName; + + if (isSameChain) { + // Same-chain swap via localTransferTo + this.logger.debug( + `MultiCollateral: same-chain swap ${originToken.symbol} -> ${destinationToken.symbol}`, + ); + const txReq = await adapter.populateLocalTransferToTx({ + targetRouter: destinationToken.addressOrDenom!, + recipient, + amount, + }); + transactions.push({ + category: WarpTxCategory.Transfer, + type: providerType, + transaction: txReq, + } as WarpTypedTransaction); + } else { + // Cross-chain transfer via transferRemoteTo + this.logger.debug( + `MultiCollateral: cross-chain ${originToken.symbol} (${originToken.chainName}) -> ${destinationToken.symbol} (${destinationName})`, + ); + const destinationDomainId = this.multiProvider.getDomainId(destination); + + const txReq = await adapter.populateTransferRemoteToTx({ + destination: destinationDomainId, + recipient, + amount, + targetRouter: destinationToken.addressOrDenom!, + }); + transactions.push({ + category: WarpTxCategory.Transfer, + type: providerType, + transaction: txReq, + } as WarpTypedTransaction); + } + + return transactions; + } + /** * Fetch local and interchain fee estimates for a remote transfer */ @@ -538,6 +667,18 @@ export class WarpCore { }): 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: destinationToken!, + recipient, + }); + } + // 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 +705,67 @@ export class WarpCore { }; } + /** + * Estimate fees for a MultiCollateral transfer. + */ + protected async estimateMultiCollateralFees({ + originTokenAmount, + destination, + destinationToken, + recipient, + }: { + originTokenAmount: TokenAmount; + destination: ChainNameOrId; + destinationToken: IToken; + recipient: Address; + }): Promise { + const { token: originToken } = originTokenAmount; + const destinationName = this.multiProvider.getChainName(destination); + const isSameChain = originToken.chainName === destinationName; + + const originMetadata = this.multiProvider.getChainMetadata( + originToken.chainName, + ); + const localGasToken = Token.FromChainMetadataNativeToken(originMetadata); + + if (isSameChain) { + return { + interchainQuote: localGasToken.amount(0n), + localQuote: localGasToken.amount(0n), + tokenFeeQuote: undefined, + }; + } + + // Cross-chain: quote from contract + assert( + originToken.collateralAddressOrDenom, + 'Origin token missing collateralAddressOrDenom', + ); + assert( + destinationToken.addressOrDenom, + 'Destination token missing addressOrDenom', + ); + + const adapter = originToken.getHypAdapter( + this.multiProvider, + destinationName, + ) as EvmHypMultiCollateralAdapter; + + const destinationDomainId = this.multiProvider.getDomainId(destination); + const { igpQuote } = await adapter.quoteTransferRemoteToGas({ + destination: destinationDomainId, + recipient, + amount: originTokenAmount.amount, + targetRouter: destinationToken.addressOrDenom!, + }); + + return { + interchainQuote: localGasToken.amount(igpQuote.amount), + localQuote: localGasToken.amount(0n), + tokenFeeQuote: undefined, + }; + } + /** * Computes the max transferrable amount of the from the given * token balance, accounting for local and interchain gas fees From 7c749f81d1a7ad13014a368769bdabb74606564c Mon Sep 17 00:00:00 2001 From: nambrot Date: Sat, 28 Feb 2026 13:27:30 -0500 Subject: [PATCH 02/26] feat: port multicollateral fee and transfer flows to sdk and cli --- typescript/cli/src/commands/warp.ts | 35 +- typescript/cli/src/config/warp.ts | 11 + typescript/cli/src/deploy/warp.ts | 45 +- typescript/cli/src/send/transfer.ts | 54 ++- typescript/cli/src/tests/commands/helpers.ts | 2 +- .../cli/src/tests/ethereum/commands/warp.ts | 13 +- .../warp/warp-multi-collateral.e2e-test.ts | 250 ++++------- .../ethereum/warp/warp-multi-peer.e2e-test.ts | 400 ------------------ .../fee/EvmTokenFeeDeployer.hardhat-test.ts | 3 + typescript/sdk/src/fee/EvmTokenFeeDeployer.ts | 25 +- typescript/sdk/src/fee/EvmTokenFeeModule.ts | 272 ++++++++---- typescript/sdk/src/fee/EvmTokenFeeReader.ts | 50 ++- typescript/sdk/src/fee/types.ts | 22 + .../src/token/EvmWarpModule.hardhat-test.ts | 5 +- typescript/sdk/src/token/TokenStandard.ts | 1 + .../EvmMultiCollateralAdapter.test.ts | 71 ++++ .../adapters/EvmMultiCollateralAdapter.ts | 30 +- .../sdk/src/token/adapters/EvmTokenAdapter.ts | 28 +- typescript/sdk/src/warp/WarpCore.test.ts | 234 ++++++++++ typescript/sdk/src/warp/WarpCore.ts | 340 ++++++++++----- 20 files changed, 1070 insertions(+), 821 deletions(-) delete mode 100644 typescript/cli/src/tests/ethereum/warp/warp-multi-peer.e2e-test.ts create mode 100644 typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index fa83c126d0d..bf57589bc23 100644 --- a/typescript/cli/src/commands/warp.ts +++ b/typescript/cli/src/commands/warp.ts @@ -204,13 +204,14 @@ const combine: CommandModuleWithWriteContext<{ 'output-warp-route-id': { type: 'string', description: - 'Warp route ID for the merged WarpCoreConfig (defaults to MULTI/route1+route2)', - demandOption: false, + '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'); + assert(outputId, '--output-warp-route-id is required'); const routeIds = routes.split(',').map((r) => r.trim()); await runWarpRouteCombine({ context, @@ -335,6 +336,8 @@ const send: CommandModuleWithWriteContext< recipient?: string; chains?: string[]; skipValidation?: boolean; + sourceToken?: string; + destinationToken?: string; } > = { command: 'send', @@ -361,6 +364,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, @@ -376,6 +388,8 @@ const send: CommandModuleWithWriteContext< roundTrip, chains: chainsArg, skipValidation, + sourceToken, + destinationToken, }) => { const warpCoreConfig = await getWarpCoreConfigOrExit({ symbol, @@ -405,10 +419,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]) @@ -427,6 +448,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 df8869d5ed2..db0c049bc45 100644 --- a/typescript/cli/src/config/warp.ts +++ b/typescript/cli/src/config/warp.ts @@ -328,6 +328,17 @@ export async function createWarpRouteDeployConfig({ isNft: false, }; break; + case TokenType.multiCollateral: + result[chain] = { + type, + owner, + proxyAdmin, + interchainSecurityModule, + token: await input({ + message: `Enter the existing token address on chain ${chain}`, + }), + }; + break; default: throw new Error(`Token type ${type} is not supported`); } diff --git a/typescript/cli/src/deploy/warp.ts b/typescript/cli/src/deploy/warp.ts index 3797d86ebbf..01f85ca0781 100644 --- a/typescript/cli/src/deploy/warp.ts +++ b/typescript/cli/src/deploy/warp.ts @@ -23,7 +23,6 @@ import { ExplorerLicenseType, HypERC20Deployer, IsmType, - type MultiCollateralTokenConfig, type MultiProvider, type MultisigIsmConfig, type OpStackIsmConfig, @@ -268,7 +267,12 @@ export async function runWarpRouteDeploy({ warpRouteIdOptions = addWarpRouteOptions; } - await writeDeploymentArtifacts(warpCoreConfig, context, warpRouteIdOptions); + await writeDeploymentArtifacts( + warpCoreConfig, + context, + warpRouteIdOptions, + warpDeployConfig, + ); await completeDeploy( context, @@ -327,10 +331,19 @@ async function writeDeploymentArtifacts( warpCoreConfig: WarpCoreConfig, context: WriteCommandContext, addWarpRouteOptions?: AddWarpRouteConfigOptions, + warpDeployConfig?: WarpRouteDeployConfigMailboxRequired, ) { log('Writing deployment artifacts...'); await context.registry.addWarpRoute(warpCoreConfig, addWarpRouteOptions); + // Save deploy config so `warp combine` can read it later + if (warpDeployConfig && addWarpRouteOptions) { + await context.registry.addWarpRouteConfig( + warpDeployConfig, + addWarpRouteOptions, + ); + } + log(indentYamlOrJson(yamlStringify(warpCoreConfig, null, 2), 4)); } @@ -1254,7 +1267,7 @@ export async function runWarpRouteCombine({ }: { context: WriteCommandContext; routeIds: string[]; - outputWarpRouteId?: string; + outputWarpRouteId: string; }): Promise { assert(routeIds.length >= 2, 'At least 2 route IDs are required to combine'); @@ -1282,9 +1295,7 @@ export async function runWarpRouteCombine({ for (const [chain, chainConfig] of Object.entries(route.deployConfig)) { if (!isMultiCollateralTokenConfig(chainConfig)) continue; - const mcConfig = chainConfig as MultiCollateralTokenConfig; - const enrolledRouters: Record = - mcConfig.enrolledRouters ?? {}; + const enrolledRouters: Record> = {}; // Look at all OTHER routes for (const otherRoute of routes) { @@ -1297,14 +1308,22 @@ export async function runWarpRouteCombine({ .toString(); const otherRouter = addressToBytes32(otherToken.addressOrDenom!); - enrolledRouters[otherDomain] ??= []; - if (!enrolledRouters[otherDomain].includes(otherRouter)) { - enrolledRouters[otherDomain].push(otherRouter); - } + enrolledRouters[otherDomain] ??= new Set(); + enrolledRouters[otherDomain].add(otherRouter); } } - (route.deployConfig[chain] as any).enrolledRouters = enrolledRouters; + const reconciledEnrolledRouters = Object.fromEntries( + Object.entries(enrolledRouters).map(([domain, routers]) => [ + domain, + [...routers], + ]), + ); + + (route.deployConfig[chain] as any).enrolledRouters = + Object.keys(reconciledEnrolledRouters).length > 0 + ? reconciledEnrolledRouters + : undefined; } // Write updated deploy config back @@ -1327,9 +1346,7 @@ export async function runWarpRouteCombine({ fullyConnectTokens(mergedConfig, context.multiProvider); // 4. Write merged WarpCoreConfig - const mergedId = - outputWarpRouteId ?? - `MULTI/${routes.map((r) => r.id.replace(/\//g, '-')).join('+')}`; + const mergedId = outputWarpRouteId; await context.registry.addWarpRoute(mergedConfig, { warpRouteId: mergedId }); logGreen(`✅ Combined ${routes.length} routes into "${mergedId}"`); diff --git a/typescript/cli/src/send/transfer.ts b/typescript/cli/src/send/transfer.ts index a8fecefec9c..65544b6238c 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 = []; @@ -181,21 +208,24 @@ async function executeDelivery({ } } 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 +234,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 8279ee36be7..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,10 @@ 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({ @@ -277,7 +286,7 @@ export function hyperlaneWarpCombine({ outputWarpRouteId, }: { routes: string; - outputWarpRouteId?: string; + outputWarpRouteId: string; }): ProcessPromise { return $`${localTestRunCmdPrefix()} hyperlane warp combine \ --registry ${REGISTRY_PATH} \ 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 index 8602a2603c4..b3d94a9033e 100644 --- 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 @@ -1,39 +1,34 @@ -import { JsonRpcProvider } from '@ethersproject/providers'; import { expect } from 'chai'; -import { Wallet, ethers } from 'ethers'; import { parseUnits } from 'ethers/lib/utils.js'; -import { MultiCollateral__factory } from '@hyperlane-xyz/core'; import { type ChainAddresses, createWarpRouteConfigId, } from '@hyperlane-xyz/registry'; import { type ChainMap, - type ChainMetadata, type Token, - TokenStandard, TokenType, type WarpCoreConfig, type WarpRouteDeployConfig, } from '@hyperlane-xyz/sdk'; -import { type Address, addressToBytes32 } from '@hyperlane-xyz/utils'; import { readYamlOrJson, writeYamlOrJson } from '../../../utils/files.js'; import { deployOrUseExistingCore } from '../commands/core.js'; -import { deployToken, getDomainId } from '../commands/helpers.js'; +import { deployToken } from '../commands/helpers.js'; import { + hyperlaneWarpApplyRaw, + hyperlaneWarpCombine, hyperlaneWarpDeploy, hyperlaneWarpSendRelay, } from '../commands/warp.js'; import { + ANVIL_DEPLOYER_ADDRESS, ANVIL_KEY, - CHAIN_2_METADATA_PATH, - CHAIN_3_METADATA_PATH, CHAIN_NAME_2, CHAIN_NAME_3, CORE_CONFIG_PATH, - TEMP_PATH, + REGISTRY_PATH, WARP_DEPLOY_OUTPUT_PATH, getCombinedWarpRoutePath, } from '../consts.js'; @@ -44,31 +39,13 @@ describe('hyperlane warp multiCollateral CLI e2e tests', async function () { let chain2Addresses: ChainAddresses = {}; let chain3Addresses: ChainAddresses = {}; - let ownerAddress: Address; - let walletChain2: Wallet; - let walletChain3: Wallet; - - let chain2DomainId: number; - let chain3DomainId: number; + 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), ]); - - const chain2Metadata: ChainMetadata = readYamlOrJson(CHAIN_2_METADATA_PATH); - const chain3Metadata: ChainMetadata = readYamlOrJson(CHAIN_3_METADATA_PATH); - - const providerChain2 = new JsonRpcProvider(chain2Metadata.rpcUrls[0].http); - const providerChain3 = new JsonRpcProvider(chain3Metadata.rpcUrls[0].http); - - walletChain2 = new Wallet(ANVIL_KEY).connect(providerChain2); - walletChain3 = new Wallet(ANVIL_KEY).connect(providerChain3); - ownerAddress = walletChain2.address; - - chain2DomainId = Number(await getDomainId(CHAIN_NAME_2, ANVIL_KEY)); - chain3DomainId = Number(await getDomainId(CHAIN_NAME_3, ANVIL_KEY)); }); it('should send cross-stablecoin transfer via CLI warp send (USDC -> USDT cross-chain)', async function () { @@ -149,7 +126,18 @@ describe('hyperlane warp multiCollateral CLI e2e tests', async function () { } as WarpRouteDeployConfig); await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, usdtWarpId); - // Read deployed configs + // 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, @@ -169,73 +157,12 @@ describe('hyperlane warp multiCollateral CLI e2e tests', async function () { const usdcRouter2Addr = usdcTokens[CHAIN_NAME_2].addressOrDenom; const usdtRouter3Addr = usdtTokens[CHAIN_NAME_3].addressOrDenom; - // Enroll cross-stablecoin routers (bidirectional) - const usdcRouter2 = MultiCollateral__factory.connect( - usdcRouter2Addr, - walletChain2, - ); - const usdtRouter3 = MultiCollateral__factory.connect( - usdtRouter3Addr, - walletChain3, - ); - - await ( - await usdcRouter2.enrollRouters( - [chain3DomainId], - [addressToBytes32(usdtRouter3Addr)], - ) - ).wait(); - await ( - await usdtRouter3.enrollRouters( - [chain2DomainId], - [addressToBytes32(usdcRouter2Addr)], - ) - ).wait(); - // Collateralize USDT router on chain 3 const usdtCollateral = parseUnits('10', 18); await (await usdtChain3.transfer(usdtRouter3Addr, usdtCollateral)).wait(); - // Build merged WarpCoreConfig for CLI warp send - const usdcCoreConfig = readYamlOrJson( - USDC_WARP_CONFIG_PATH, - ) as WarpCoreConfig; - const usdtCoreConfig = readYamlOrJson( - USDT_WARP_CONFIG_PATH, - ) as WarpCoreConfig; - - // Find the specific tokens - const usdcToken2 = usdcCoreConfig.tokens.find( - (t) => t.chainName === CHAIN_NAME_2, - )!; - const usdtToken3 = usdtCoreConfig.tokens.find( - (t) => t.chainName === CHAIN_NAME_3, - )!; - - // Create merged config with cross-stablecoin connections - const mergedConfig: WarpCoreConfig = { - tokens: [ - { - ...usdcToken2, - connections: [ - { - token: `ethereum|${CHAIN_NAME_3}|${usdtRouter3Addr}`, - }, - ], - }, - { - ...usdtToken3, - connections: [ - { - token: `ethereum|${CHAIN_NAME_2}|${usdcRouter2Addr}`, - }, - ], - }, - ], - }; - - const MERGED_CONFIG_PATH = `${TEMP_PATH}/multi-collateral-merged-config.yaml`; - writeYamlOrJson(MERGED_CONFIG_PATH, mergedConfig); + // 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 @@ -272,92 +199,71 @@ describe('hyperlane warp multiCollateral CLI e2e tests', async function () { 'Local USDT 2', ); - // Deploy MultiCollateral routers programmatically - const usdcScale = ethers.BigNumber.from(10).pow(12); - const usdtScale = ethers.BigNumber.from(1); + // 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); - const usdcRouter = await new MultiCollateral__factory(walletChain2).deploy( - usdc.address, - usdcScale, - chain2Addresses.mailbox, - ); - await usdcRouter.deployed(); - await ( - await usdcRouter.initialize( - ethers.constants.AddressZero, - ethers.constants.AddressZero, - ownerAddress, - ) - ).wait(); + // 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); - const usdtRouter = await new MultiCollateral__factory(walletChain2).deploy( - usdt.address, - usdtScale, - chain2Addresses.mailbox, - ); - await usdtRouter.deployed(); - await ( - await usdtRouter.initialize( - ethers.constants.AddressZero, - ethers.constants.AddressZero, - ownerAddress, - ) - ).wait(); + // 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, + ]); - // Enroll bidirectionally (same domain) - const usdcRouterBytes32 = addressToBytes32(usdcRouter.address); - const usdtRouterBytes32 = addressToBytes32(usdtRouter.address); + 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 }), {}); - await ( - await usdcRouter.enrollRouters([chain2DomainId], [usdtRouterBytes32]) - ).wait(); - await ( - await usdtRouter.enrollRouters([chain2DomainId], [usdcRouterBytes32]) - ).wait(); + 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(usdtRouter.address, usdtCollateral)).wait(); - - // Build WarpCoreConfig for CLI - const mergedConfig: WarpCoreConfig = { - tokens: [ - { - chainName: CHAIN_NAME_2, - standard: TokenStandard.EvmHypMultiCollateral, - decimals: 6, - symbol: 'LUSDC2', - name: 'Local USDC 2', - addressOrDenom: usdcRouter.address, - collateralAddressOrDenom: usdc.address, - connections: [ - { - token: `ethereum|${CHAIN_NAME_2}|${usdtRouter.address}`, - }, - ], - }, - { - chainName: CHAIN_NAME_2, - standard: TokenStandard.EvmHypMultiCollateral, - decimals: 18, - symbol: 'LUSDT2', - name: 'Local USDT 2', - addressOrDenom: usdtRouter.address, - collateralAddressOrDenom: usdt.address, - connections: [ - { - token: `ethereum|${CHAIN_NAME_2}|${usdcRouter.address}`, - }, - ], - }, - ], - }; + await (await usdt.transfer(usdtRouter2Addr, usdtCollateral)).wait(); - const MERGED_CONFIG_PATH = `${TEMP_PATH}/multi-collateral-local-merged.yaml`; - writeYamlOrJson(MERGED_CONFIG_PATH, mergedConfig); + // 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 - // CLI defaults recipient to signer (ownerAddress) const swapAmount = parseUnits('1', 6); // 1 USDC const balanceBefore = await usdt.balanceOf(ownerAddress); @@ -365,10 +271,10 @@ describe('hyperlane warp multiCollateral CLI e2e tests', async function () { origin: CHAIN_NAME_2, destination: CHAIN_NAME_2, warpCorePath: MERGED_CONFIG_PATH, - sourceToken: usdcRouter.address, - destinationToken: usdtRouter.address, + sourceToken: usdcRouter2Addr, + destinationToken: usdtRouter2Addr, value: swapAmount.toString(), - relay: false, // No relay needed for same-chain + relay: false, // Same-chain: handle() called directly, no relay needed skipValidation: true, }); diff --git a/typescript/cli/src/tests/ethereum/warp/warp-multi-peer.e2e-test.ts b/typescript/cli/src/tests/ethereum/warp/warp-multi-peer.e2e-test.ts deleted file mode 100644 index c489556a22a..00000000000 --- a/typescript/cli/src/tests/ethereum/warp/warp-multi-peer.e2e-test.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { JsonRpcProvider } from '@ethersproject/providers'; -import { expect } from 'chai'; -import { Wallet, ethers } from 'ethers'; -import { parseUnits } from 'ethers/lib/utils.js'; - -import { MultiCollateral__factory } from '@hyperlane-xyz/core'; -import { - type ChainAddresses, - createWarpRouteConfigId, -} from '@hyperlane-xyz/registry'; -import { - type ChainMap, - type ChainMetadata, - type Token, - TokenType, - type WarpCoreConfig, - type WarpRouteDeployConfig, -} from '@hyperlane-xyz/sdk'; -import { type Address, addressToBytes32 } from '@hyperlane-xyz/utils'; - -import { readYamlOrJson, writeYamlOrJson } from '../../../utils/files.js'; -import { deployOrUseExistingCore } from '../commands/core.js'; -import { - deployToken, - getDomainId, - hyperlaneStatus, -} from '../commands/helpers.js'; -import { - hyperlaneWarpDeploy, - sendWarpRouteMessageRoundTrip, -} from '../commands/warp.js'; -import { - ANVIL_KEY, - CHAIN_2_METADATA_PATH, - CHAIN_3_METADATA_PATH, - CHAIN_NAME_2, - CHAIN_NAME_3, - CORE_CONFIG_PATH, - WARP_DEPLOY_OUTPUT_PATH, - getCombinedWarpRoutePath, -} from '../consts.js'; - -describe('hyperlane warp multiCollateral e2e tests', async function () { - this.timeout(200_000); - - let chain2Addresses: ChainAddresses = {}; - let chain3Addresses: ChainAddresses = {}; - - let ownerAddress: Address; - let walletChain2: Wallet; - let walletChain3: Wallet; - - 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), - ]); - - const chain2Metadata: ChainMetadata = readYamlOrJson(CHAIN_2_METADATA_PATH); - const chain3Metadata: ChainMetadata = readYamlOrJson(CHAIN_3_METADATA_PATH); - - const providerChain2 = new JsonRpcProvider(chain2Metadata.rpcUrls[0].http); - const providerChain3 = new JsonRpcProvider(chain3Metadata.rpcUrls[0].http); - - walletChain2 = new Wallet(ANVIL_KEY).connect(providerChain2); - walletChain3 = new Wallet(ANVIL_KEY).connect(providerChain3); - ownerAddress = walletChain2.address; - }); - - it('should bridge same-stablecoin round trip (multiCollateral <-> multiCollateral)', async function () { - // Deploy same token on both chains - const tokenChain2 = await deployToken( - ANVIL_KEY, - CHAIN_NAME_2, - 6, - 'USDC', - 'USD Coin', - ); - const tokenChain3 = await deployToken( - ANVIL_KEY, - CHAIN_NAME_3, - 6, - 'USDC', - 'USD Coin', - ); - const tokenSymbol = await tokenChain2.symbol(); - - const WARP_CORE_CONFIG_PATH = getCombinedWarpRoutePath(tokenSymbol, [ - CHAIN_NAME_2, - CHAIN_NAME_3, - ]); - const warpId = createWarpRouteConfigId( - tokenSymbol, - `${CHAIN_NAME_2}-${CHAIN_NAME_3}`, - ); - - // Deploy config: multiCollateral on both chains - const warpConfig: WarpRouteDeployConfig = { - [CHAIN_NAME_2]: { - type: TokenType.multiCollateral, - token: tokenChain2.address, - mailbox: chain2Addresses.mailbox, - owner: ownerAddress, - }, - [CHAIN_NAME_3]: { - type: TokenType.multiCollateral, - token: tokenChain3.address, - mailbox: chain3Addresses.mailbox, - owner: ownerAddress, - }, - }; - - writeYamlOrJson(WARP_DEPLOY_OUTPUT_PATH, warpConfig); - await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, warpId); - - // Read deployed config to get router addresses - const config: ChainMap = ( - readYamlOrJson(WARP_CORE_CONFIG_PATH) as WarpCoreConfig - ).tokens.reduce((acc, curr) => ({ ...acc, [curr.chainName]: curr }), {}); - - // Collateralize both routers - const decimals = await tokenChain2.decimals(); - const collateralAmount = parseUnits('2', decimals); - - await ( - await tokenChain2.transfer( - config[CHAIN_NAME_2].addressOrDenom, - collateralAmount, - ) - ).wait(); - await ( - await tokenChain3.transfer( - config[CHAIN_NAME_3].addressOrDenom, - collateralAmount, - ) - ).wait(); - - // Send round trip via standard transferRemote (same stablecoin = enrolled routers) - await sendWarpRouteMessageRoundTrip( - CHAIN_NAME_2, - CHAIN_NAME_3, - WARP_CORE_CONFIG_PATH, - ); - }); - - it('should bridge cross-stablecoin via transferRemoteTo (USDC -> USDT)', async function () { - // Deploy USDC(6dec) on both chains and USDT(18dec) on both chains - // Deploy sequentially per chain to avoid nonce collisions - const usdcChain2 = await deployToken( - ANVIL_KEY, - CHAIN_NAME_2, - 6, - 'MUSDC', - 'Mock USDC', - ); - const usdtChain2 = await deployToken( - ANVIL_KEY, - CHAIN_NAME_2, - 18, - 'MUSDT', - 'Mock USDT', - ); - const usdcChain3 = await deployToken( - ANVIL_KEY, - CHAIN_NAME_3, - 6, - 'MUSDC', - 'Mock USDC', - ); - const usdtChain3 = await deployToken( - ANVIL_KEY, - CHAIN_NAME_3, - 18, - 'MUSDT', - 'Mock USDT', - ); - - // Deploy USDC warp route (multiCollateral <-> multiCollateral) - const usdcSymbol = await usdcChain2.symbol(); - const USDC_WARP_CONFIG_PATH = getCombinedWarpRoutePath(usdcSymbol, [ - CHAIN_NAME_2, - CHAIN_NAME_3, - ]); - const usdcWarpId = createWarpRouteConfigId( - usdcSymbol, - `${CHAIN_NAME_2}-${CHAIN_NAME_3}`, - ); - - // USDC = 6 decimals, scale = 1e12 to normalize to 18-dec canonical - const usdcScale = 1e12; - 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, - }, - }); - await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, usdcWarpId); - - // Deploy USDT warp route (multiCollateral <-> multiCollateral) - const usdtSymbol = await usdtChain2.symbol(); - const USDT_WARP_CONFIG_PATH = getCombinedWarpRoutePath(usdtSymbol, [ - CHAIN_NAME_2, - CHAIN_NAME_3, - ]); - const usdtWarpId = createWarpRouteConfigId( - usdtSymbol, - `${CHAIN_NAME_2}-${CHAIN_NAME_3}`, - ); - - // USDT = 18 decimals, scale = 1 (default, canonical = local) - 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, - }, - }); - await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, usdtWarpId); - - // Read deployed configs to get router addresses - 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; - - // Connect to routers - const usdcRouter2 = MultiCollateral__factory.connect( - usdcRouter2Addr, - walletChain2, - ); - const usdtRouter3 = MultiCollateral__factory.connect( - usdtRouter3Addr, - walletChain3, - ); - - // Get domain IDs - const chain3DomainId = Number(await getDomainId(CHAIN_NAME_3, ANVIL_KEY)); - const chain2DomainId = Number(await getDomainId(CHAIN_NAME_2, ANVIL_KEY)); - - // Enroll cross-stablecoin routers (bidirectional) - const usdcRouter2Bytes32 = addressToBytes32(usdcRouter2Addr); - const usdtRouter3Bytes32 = addressToBytes32(usdtRouter3Addr); - - await ( - await usdcRouter2.enrollRouters([chain3DomainId], [usdtRouter3Bytes32]) - ).wait(); - await ( - await usdtRouter3.enrollRouters([chain2DomainId], [usdcRouter2Bytes32]) - ).wait(); - - // Collateralize USDT router on chain 3 - const usdtCollateral = parseUnits('10', 18); - await (await usdtChain3.transfer(usdtRouter3Addr, usdtCollateral)).wait(); - - // Approve USDC for usdcRouter2 - const sendAmount = parseUnits('1', 6); // 1 USDC - await (await usdcChain2.approve(usdcRouter2Addr, sendAmount)).wait(); - - // Send cross-stablecoin transfer via transferRemoteTo - const recipientAddr = '0x0000000000000000000000000000000000000042'; - const recipientBytes32 = addressToBytes32(recipientAddr); - - // Quote gas payment for protocol fee - const gasPayment = await usdcRouter2.quoteGasPayment(chain3DomainId); - - const tx = await usdcRouter2.transferRemoteTo( - chain3DomainId, - recipientBytes32, - sendAmount, - usdtRouter3Bytes32, - { value: gasPayment }, - ); - const receipt = await tx.wait(); - - // Relay the message - await hyperlaneStatus({ - origin: CHAIN_NAME_2, - dispatchTx: receipt.transactionHash, - relay: true, - key: ANVIL_KEY, - }); - - // Verify: 1 USDC(6dec) → canonical 1e18 → 1 USDT(18dec) = 1e18 - const recipientBalance = await usdtChain3.balanceOf(recipientAddr); - expect(recipientBalance.toString()).to.equal( - parseUnits('1', 18).toString(), - ); - }); - - it('should swap same-chain via localTransferTo (USDC -> USDT)', async function () { - // Deploy USDC(6dec) and USDT(18dec) on chain 2 - const usdc = await deployToken( - ANVIL_KEY, - CHAIN_NAME_2, - 6, - 'LUSDC', - 'Local USDC', - ); - const usdt = await deployToken( - ANVIL_KEY, - CHAIN_NAME_2, - 18, - 'LUSDT', - 'Local USDT', - ); - - // Deploy MultiCollateral routers programmatically - const usdcScale = ethers.BigNumber.from(10).pow(12); // 6→18 dec scaling - const usdtScale = ethers.BigNumber.from(1); // 18→18 dec (no scaling) - - const usdcRouter = await new MultiCollateral__factory(walletChain2).deploy( - usdc.address, - usdcScale, - chain2Addresses.mailbox, - ); - await usdcRouter.deployed(); - await ( - await usdcRouter.initialize( - ethers.constants.AddressZero, - ethers.constants.AddressZero, - ownerAddress, - ) - ).wait(); - - const usdtRouter = await new MultiCollateral__factory(walletChain2).deploy( - usdt.address, - usdtScale, - chain2Addresses.mailbox, - ); - await usdtRouter.deployed(); - await ( - await usdtRouter.initialize( - ethers.constants.AddressZero, - ethers.constants.AddressZero, - ownerAddress, - ) - ).wait(); - - // Get local domain ID for router enrollment - const localDomainId = Number(await getDomainId(CHAIN_NAME_2, ANVIL_KEY)); - - // Enroll as local routers (bidirectional) - const usdcRouterBytes32 = addressToBytes32(usdcRouter.address); - const usdtRouterBytes32 = addressToBytes32(usdtRouter.address); - - await ( - await usdcRouter.enrollRouters([localDomainId], [usdtRouterBytes32]) - ).wait(); - await ( - await usdtRouter.enrollRouters([localDomainId], [usdcRouterBytes32]) - ).wait(); - - // Collateralize USDT router - const usdtCollateral = parseUnits('10', 18); - await (await usdt.transfer(usdtRouter.address, usdtCollateral)).wait(); - - // Approve USDC for usdcRouter - const swapAmount = parseUnits('1', 6); // 1 USDC - await (await usdc.approve(usdcRouter.address, swapAmount)).wait(); - - // Execute same-chain swap - const recipientAddr = '0x0000000000000000000000000000000000000043'; - const balanceBefore = await usdt.balanceOf(recipientAddr); - - await ( - await usdcRouter.localTransferTo( - usdtRouter.address, - recipientAddr, - swapAmount, - ) - ).wait(); - - // Verify: 1 USDC(6dec) → canonical 1e18 → 1 USDT(18dec) = 1e18 - const balanceAfter = await usdt.balanceOf(recipientAddr); - const received = balanceAfter.sub(balanceBefore); - expect(received.toString()).to.equal(parseUnits('1', 18).toString()); - }); -}); diff --git a/typescript/sdk/src/fee/EvmTokenFeeDeployer.hardhat-test.ts b/typescript/sdk/src/fee/EvmTokenFeeDeployer.hardhat-test.ts index 5a4ab11c46a..17f12cd97db 100644 --- a/typescript/sdk/src/fee/EvmTokenFeeDeployer.hardhat-test.ts +++ b/typescript/sdk/src/fee/EvmTokenFeeDeployer.hardhat-test.ts @@ -1,5 +1,6 @@ import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers.js'; import { expect } from 'chai'; +import { constants } from 'ethers'; import hre from 'hardhat'; import { ERC20Test, ERC20Test__factory } from '@hyperlane-xyz/core'; @@ -185,6 +186,7 @@ describe('EvmTokenFeeDeployer', () => { // Read the actual address of the deployed routing fee contract const actualLinearFeeAddress = await routingFeeContract.feeContracts( multiProvider.getChainId(TestChainName.test2), + constants.HashZero, ); expect(actualLinearFeeAddress).to.equal( @@ -224,6 +226,7 @@ describe('EvmTokenFeeDeployer', () => { const actualLinearFeeAddress = await routingFeeContract.feeContracts( multiProvider.getChainId(TestChainName.test2), + constants.HashZero, ); expect(actualLinearFeeAddress).to.equal(linearFeeContract.address); expect(await linearFeeContract.owner()).to.equal(otherSigner.address); diff --git a/typescript/sdk/src/fee/EvmTokenFeeDeployer.ts b/typescript/sdk/src/fee/EvmTokenFeeDeployer.ts index 7e62e83aea3..eb8374e500e 100644 --- a/typescript/sdk/src/fee/EvmTokenFeeDeployer.ts +++ b/typescript/sdk/src/fee/EvmTokenFeeDeployer.ts @@ -133,7 +133,30 @@ export class EvmTokenFeeDeployer extends HyperlaneDeployer< this.multiProvider.getTransactionOverrides(chain), ), ); - subFeeContracts[destinationChain] = deployedFeeContract; + subFeeContracts[destinationChain] = + deployedFeeContract as unknown as BaseFee; + } + } + + if (config.routerFeeContracts) { + for (const [destinationChain, routerMap] of Object.entries( + config.routerFeeContracts, + )) { + for (const [routerBytes32, feeConfig] of Object.entries(routerMap)) { + const deployedFeeContract = await this.deployFee(chain, feeConfig); + + await this.multiProvider.handleTx( + chain, + routingFee.setRouterFeeContract( + this.multiProvider.getChainId(destinationChain), + routerBytes32, + deployedFeeContract.address, + this.multiProvider.getTransactionOverrides(chain), + ), + ); + subFeeContracts[`router:${destinationChain}:${routerBytes32}`] = + deployedFeeContract as unknown as BaseFee; + } } } diff --git a/typescript/sdk/src/fee/EvmTokenFeeModule.ts b/typescript/sdk/src/fee/EvmTokenFeeModule.ts index d4893804f11..d6fdc652303 100644 --- a/typescript/sdk/src/fee/EvmTokenFeeModule.ts +++ b/typescript/sdk/src/fee/EvmTokenFeeModule.ts @@ -53,21 +53,32 @@ function resolveTokenForFeeConfig( config: TokenFeeConfigInput, token: Address, ): ResolvedTokenFeeConfigInput { - if ( - config.type === TokenFeeType.RoutingFee && - 'feeContracts' in config && - config.feeContracts - ) { - return { - ...config, - token, - feeContracts: Object.fromEntries( + if (config.type === TokenFeeType.RoutingFee) { + const resolved: ResolvedTokenFeeConfigInput = { ...config, token }; + if ('feeContracts' in config && config.feeContracts) { + resolved.feeContracts = Object.fromEntries( Object.entries(config.feeContracts).map(([chain, subFee]) => [ chain, resolveTokenForFeeConfig(subFee, token), ]), - ), - }; + ); + } + if ('routerFeeContracts' in config && config.routerFeeContracts) { + resolved.routerFeeContracts = Object.fromEntries( + Object.entries(config.routerFeeContracts).map(([chain, routerMap]) => [ + chain, + Object.fromEntries( + Object.entries( + routerMap as Record, + ).map(([routerBytes32, subFee]) => [ + routerBytes32, + resolveTokenForFeeConfig(subFee, token), + ]), + ), + ]), + ); + } + return resolved; } return { ...config, token }; } @@ -200,6 +211,8 @@ export class EvmTokenFeeModule extends HyperlaneModule< const { token, owner } = config; const inputFeeContracts = 'feeContracts' in config ? config.feeContracts : undefined; + const inputRouterFeeContracts = + 'routerFeeContracts' in config ? config.routerFeeContracts : undefined; const feeContracts = inputFeeContracts ? await promiseObjAll( @@ -220,6 +233,33 @@ export class EvmTokenFeeModule extends HyperlaneModule< ) : undefined; + let routerFeeContracts: + | Record> + | undefined; + if (inputRouterFeeContracts) { + routerFeeContracts = {}; + for (const [chain, routerMap] of Object.entries( + inputRouterFeeContracts, + )) { + routerFeeContracts[chain] = await promiseObjAll( + objMap( + routerMap as Record, + async (_, innerConfig) => { + const resolvedInnerConfig: ResolvedTokenFeeConfigInput = { + ...innerConfig, + token: innerConfig.token ?? token, + }; + return EvmTokenFeeModule.expandConfig({ + config: resolvedInnerConfig, + multiProvider, + chainName, + }); + }, + ), + ); + } + } + intermediaryConfig = { type: TokenFeeType.RoutingFee, token, @@ -227,6 +267,7 @@ export class EvmTokenFeeModule extends HyperlaneModule< maxFee: constants.MaxUint256.toBigInt(), halfAmount: constants.MaxUint256.toBigInt(), feeContracts, + routerFeeContracts, }; } else { // Progressive/Regressive fees @@ -295,18 +336,32 @@ export class EvmTokenFeeModule extends HyperlaneModule< let updateTransactions: AnnotatedEV5Transaction[] = []; - // Derive routingDestinations from target config if not provided + // Derive routingDestinations and routersByDestination from target config if not provided // This ensures we read all sub-fee configs that need to be compared/updated let effectiveParams = params; - if ( - !params?.routingDestinations && - targetConfig.type === TokenFeeType.RoutingFee && - !isNullish(targetConfig.feeContracts) - ) { - const routingDestinations = Object.keys(targetConfig.feeContracts).map( - (chainName) => this.multiProvider.getDomainId(chainName), - ); - effectiveParams = { ...params, routingDestinations }; + if (targetConfig.type === TokenFeeType.RoutingFee) { + if ( + !params?.routingDestinations && + !isNullish(targetConfig.feeContracts) + ) { + const routingDestinations = Object.keys(targetConfig.feeContracts).map( + (chainName) => this.multiProvider.getDomainId(chainName), + ); + effectiveParams = { ...effectiveParams, routingDestinations }; + } + if ( + !params?.routersByDestination && + !isNullish(targetConfig.routerFeeContracts) + ) { + const routersByDestination: Record = {}; + for (const [chainName, routerMap] of Object.entries( + targetConfig.routerFeeContracts, + )) { + const domainId = this.multiProvider.getDomainId(chainName); + routersByDestination[domainId] = Object.keys(routerMap); + } + effectiveParams = { ...effectiveParams, routersByDestination }; + } } const actualConfig = await this.read(effectiveParams); @@ -382,62 +437,31 @@ export class EvmTokenFeeModule extends HyperlaneModule< private async updateRoutingFee(targetConfig: DerivedRoutingFeeConfig) { const updateTransactions: AnnotatedEV5Transaction[] = []; - if (!targetConfig.feeContracts) return []; const currentRoutingAddress = this.args.addresses.deployedFee; - for (const [chainName, config] of Object.entries( - targetConfig.feeContracts, - )) { - const address = config.address; - - let subFeeModule: EvmTokenFeeModule; - let deployedSubFee: string; - - if (!address) { - // Sub-fee contract doesn't exist yet, deploy a new one - this.logger.info( - `No existing sub-fee contract for ${chainName}, deploying new one`, - ); - subFeeModule = await EvmTokenFeeModule.create({ - multiProvider: this.multiProvider, - chain: this.chainName, - config, - contractVerifier: this.contractVerifier, - }); - deployedSubFee = subFeeModule.serialize().deployedFee; - - const annotation = `New sub fee contract deployed. Setting contract for ${chainName} to ${deployedSubFee}`; - this.logger.debug(annotation); - updateTransactions.push({ - annotation: annotation, - chainId: this.chainId, - to: currentRoutingAddress, - data: RoutingFee__factory.createInterface().encodeFunctionData( - 'setFeeContract(uint32,address)', - [this.multiProvider.getDomainId(chainName), deployedSubFee], - ), - }); - } else { - // Update existing sub-fee contract - subFeeModule = new EvmTokenFeeModule( - this.multiProvider, - { - addresses: { - deployedFee: address, - }, + + if (targetConfig.feeContracts) { + for (const [chainName, config] of Object.entries( + targetConfig.feeContracts, + )) { + const address = config.address; + + let subFeeModule: EvmTokenFeeModule; + let deployedSubFee: string; + + if (!address) { + // Sub-fee contract doesn't exist yet, deploy a new one + this.logger.info( + `No existing sub-fee contract for ${chainName}, deploying new one`, + ); + subFeeModule = await EvmTokenFeeModule.create({ + multiProvider: this.multiProvider, chain: this.chainName, config, - }, - this.contractVerifier, - ); - const subFeeUpdateTransactions = await subFeeModule.update(config, { - address, - }); - deployedSubFee = subFeeModule.serialize().deployedFee; - - updateTransactions.push(...subFeeUpdateTransactions); + contractVerifier: this.contractVerifier, + }); + deployedSubFee = subFeeModule.serialize().deployedFee; - if (!eqAddress(deployedSubFee, address)) { - const annotation = `Sub fee contract redeployed on chain ${this.chainName}. Updating fee contract for destination ${chainName} to ${deployedSubFee}`; + const annotation = `New sub fee contract deployed. Setting contract for ${chainName} to ${deployedSubFee}`; this.logger.debug(annotation); updateTransactions.push({ annotation: annotation, @@ -448,6 +472,108 @@ export class EvmTokenFeeModule extends HyperlaneModule< [this.multiProvider.getDomainId(chainName), deployedSubFee], ), }); + } else { + // Update existing sub-fee contract + subFeeModule = new EvmTokenFeeModule( + this.multiProvider, + { + addresses: { + deployedFee: address, + }, + chain: this.chainName, + config, + }, + this.contractVerifier, + ); + const subFeeUpdateTransactions = await subFeeModule.update(config, { + address, + }); + deployedSubFee = subFeeModule.serialize().deployedFee; + + updateTransactions.push(...subFeeUpdateTransactions); + + if (!eqAddress(deployedSubFee, address)) { + const annotation = `Sub fee contract redeployed on chain ${this.chainName}. Updating fee contract for destination ${chainName} to ${deployedSubFee}`; + this.logger.debug(annotation); + updateTransactions.push({ + annotation: annotation, + chainId: this.chainId, + to: currentRoutingAddress, + data: RoutingFee__factory.createInterface().encodeFunctionData( + 'setFeeContract(uint32,address)', + [this.multiProvider.getDomainId(chainName), deployedSubFee], + ), + }); + } + } + } + } + + if (targetConfig.routerFeeContracts) { + for (const [chainName, routerMap] of Object.entries( + targetConfig.routerFeeContracts, + )) { + const destinationDomain = this.multiProvider.getDomainId(chainName); + for (const [routerBytes32, config] of Object.entries(routerMap)) { + const address = config.address; + + let subFeeModule: EvmTokenFeeModule; + let deployedSubFee: string; + + if (!address) { + this.logger.info( + `No existing router fee contract for ${chainName}/${routerBytes32}, deploying new one`, + ); + subFeeModule = await EvmTokenFeeModule.create({ + multiProvider: this.multiProvider, + chain: this.chainName, + config, + contractVerifier: this.contractVerifier, + }); + deployedSubFee = subFeeModule.serialize().deployedFee; + + const annotation = `New router fee contract deployed. Setting contract for ${chainName}/${routerBytes32} to ${deployedSubFee}`; + this.logger.debug(annotation); + updateTransactions.push({ + annotation, + chainId: this.chainId, + to: currentRoutingAddress, + data: RoutingFee__factory.createInterface().encodeFunctionData( + 'setRouterFeeContract', + [destinationDomain, routerBytes32, deployedSubFee], + ), + }); + } else { + subFeeModule = new EvmTokenFeeModule( + this.multiProvider, + { + addresses: { deployedFee: address }, + chain: this.chainName, + config, + }, + this.contractVerifier, + ); + const subFeeUpdateTransactions = await subFeeModule.update(config, { + address, + }); + deployedSubFee = subFeeModule.serialize().deployedFee; + + updateTransactions.push(...subFeeUpdateTransactions); + + if (!eqAddress(deployedSubFee, address)) { + const annotation = `Router fee contract redeployed on chain ${this.chainName}. Updating for ${chainName}/${routerBytes32} to ${deployedSubFee}`; + this.logger.debug(annotation); + updateTransactions.push({ + annotation, + chainId: this.chainId, + to: currentRoutingAddress, + data: RoutingFee__factory.createInterface().encodeFunctionData( + 'setRouterFeeContract', + [destinationDomain, routerBytes32, deployedSubFee], + ), + }); + } + } } } } diff --git a/typescript/sdk/src/fee/EvmTokenFeeReader.ts b/typescript/sdk/src/fee/EvmTokenFeeReader.ts index 0a53619dcc2..80a132c67a2 100644 --- a/typescript/sdk/src/fee/EvmTokenFeeReader.ts +++ b/typescript/sdk/src/fee/EvmTokenFeeReader.ts @@ -5,7 +5,7 @@ import { LinearFee__factory, RoutingFee__factory, } from '@hyperlane-xyz/core'; -import { Address, WithAddress } from '@hyperlane-xyz/utils'; +import { Address, WithAddress, addressToBytes32 } from '@hyperlane-xyz/utils'; import { MultiProvider } from '../providers/MultiProvider.js'; import { ChainName, ChainNameOrId } from '../types.js'; @@ -28,11 +28,13 @@ export type DerivedTokenFeeConfig = WithAddress; export type DerivedRoutingFeeConfig = WithAddress & { feeContracts: Record; + routerFeeContracts?: Record>; }; export type TokenFeeReaderParams = { address: Address; routingDestinations?: number[]; // Optional: when provided, derives feeContracts + routersByDestination?: Record; // Optional: destination domain -> router addresses, derives routerFeeContracts }; export class EvmTokenFeeReader extends HyperlaneReader { @@ -46,7 +48,7 @@ export class EvmTokenFeeReader extends HyperlaneReader { async deriveTokenFeeConfig( params: TokenFeeReaderParams, ): Promise { - const { address, routingDestinations } = params; + const { address, routingDestinations, routersByDestination } = params; const tokenFee = BaseFee__factory.connect(address, this.provider); let derivedConfig: DerivedTokenFeeConfig; @@ -65,6 +67,7 @@ export class EvmTokenFeeReader extends HyperlaneReader { derivedConfig = await this.deriveRoutingFeeConfig({ address, routingDestinations, + routersByDestination, }); break; default: @@ -114,7 +117,7 @@ export class EvmTokenFeeReader extends HyperlaneReader { private async deriveRoutingFeeConfig( params: TokenFeeReaderParams, ): Promise { - const { address, routingDestinations } = params; + const { address, routingDestinations, routersByDestination } = params; const routingFee = RoutingFee__factory.connect(address, this.provider); const [token, owner, maxFee, halfAmount] = await Promise.all([ routingFee.token(), @@ -131,7 +134,10 @@ export class EvmTokenFeeReader extends HyperlaneReader { if (routingDestinations) await Promise.all( routingDestinations.map(async (destination) => { - const subFeeAddress = await routingFee.feeContracts(destination); + const subFeeAddress = await routingFee.feeContracts( + destination, + constants.HashZero, + ); if (subFeeAddress === constants.AddressZero) return; const chainName = this.multiProvider.getChainName(destination); feeContracts[chainName] = await this.deriveTokenFeeConfig({ @@ -143,6 +149,39 @@ export class EvmTokenFeeReader extends HyperlaneReader { }); }), ); + + const routerFeeContracts: Record< + ChainName, + Record + > = {}; + if (routersByDestination) { + await Promise.all( + Object.entries(routersByDestination).map( + async ([destStr, routerAddrs]) => { + const destination = Number(destStr); + const chainName = this.multiProvider.getChainName(destination); + const perRouter: Record = {}; + await Promise.all( + routerAddrs.map(async (routerAddr) => { + const routerBytes32 = addressToBytes32(routerAddr); + const subFeeAddress = await routingFee.feeContracts( + destination, + routerBytes32, + ); + if (subFeeAddress === constants.AddressZero) return; + perRouter[routerBytes32] = await this.deriveTokenFeeConfig({ + address: subFeeAddress, + }); + }), + ); + if (Object.keys(perRouter).length > 0) { + routerFeeContracts[chainName] = perRouter; + } + }, + ), + ); + } + return { type: TokenFeeType.RoutingFee, maxFee: maxFeeBn, @@ -151,6 +190,9 @@ export class EvmTokenFeeReader extends HyperlaneReader { token, owner, feeContracts, + ...(Object.keys(routerFeeContracts).length > 0 && { + routerFeeContracts, + }), }; } diff --git a/typescript/sdk/src/fee/types.ts b/typescript/sdk/src/fee/types.ts index 4ec058bec2c..4dc598f0c76 100644 --- a/typescript/sdk/src/fee/types.ts +++ b/typescript/sdk/src/fee/types.ts @@ -155,6 +155,15 @@ export const RoutingFeeConfigSchema = BaseFeeConfigSchema.extend({ z.lazy((): z.ZodSchema => TokenFeeConfigSchema), ) .optional(), // Destination -> Fee + routerFeeContracts: z + .record( + ZChainName, + z.record( + ZHash, + z.lazy((): z.ZodSchema => TokenFeeConfigSchema), + ), + ) + .optional(), // Destination -> TargetRouter (bytes32) -> Fee maxFee: ZBigNumberish.optional(), halfAmount: ZBigNumberish.optional(), }); @@ -169,6 +178,15 @@ export const RoutingFeeInputConfigSchema = BaseFeeConfigInputSchema.extend({ z.lazy((): z.ZodSchema => TokenFeeConfigInputSchema), ) .optional(), + routerFeeContracts: z + .record( + ZChainName, + z.record( + ZHash, + z.lazy((): z.ZodSchema => TokenFeeConfigInputSchema), + ), + ) + .optional(), }); export type RoutingFeeInputConfig = z.infer; @@ -199,4 +217,8 @@ export type ResolvedTokenFeeConfigInput = TokenFeeConfigInput & { export type ResolvedRoutingFeeConfigInput = RoutingFeeInputConfig & { token: string; feeContracts?: Record; + routerFeeContracts?: Record< + string, + Record + >; }; diff --git a/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts b/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts index ee67f4fbd7e..b3153939088 100644 --- a/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts +++ b/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts @@ -166,7 +166,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 = [ diff --git a/typescript/sdk/src/token/TokenStandard.ts b/typescript/sdk/src/token/TokenStandard.ts index 0e1c69c5c9c..c974e8d8bda 100644 --- a/typescript/sdk/src/token/TokenStandard.ts +++ b/typescript/sdk/src/token/TokenStandard.ts @@ -178,6 +178,7 @@ export const TOKEN_COLLATERALIZED_STANDARDS = [ TokenStandard.RadixHypCollateral, TokenStandard.StarknetHypCollateral, TokenStandard.StarknetHypNative, + TokenStandard.EvmHypMultiCollateral, ]; export const XERC20_STANDARDS = [ 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..4dcd9291a48 --- /dev/null +++ b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts @@ -0,0 +1,71 @@ +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).contract = { 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).contract = { 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); + }); +}); diff --git a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts index e0f647a0a32..bbc7071601b 100644 --- a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts +++ b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts @@ -27,8 +27,7 @@ import { /** * Adapter for MultiCollateral routers. - * Supports transferRemoteTo (cross-chain to specific router) and - * localTransferTo (same-chain swap). + * Supports transferRemoteTo for both cross-chain and same-chain transfers. */ export class EvmHypMultiCollateralAdapter extends BaseEvmAdapter @@ -224,21 +223,6 @@ export class EvmHypMultiCollateralAdapter ); } - /** - * Populate same-chain local transfer to an enrolled router. - */ - async populateLocalTransferToTx(params: { - targetRouter: Address; - recipient: Address; - amount: Numberish; - }): Promise { - return this.contract.populateTransaction.localTransferTo( - params.targetRouter, - params.recipient, - params.amount.toString(), - ); - } - /** * Quote fees for transferRemoteTo. */ @@ -258,8 +242,20 @@ export class EvmHypMultiCollateralAdapter targetRouterBytes32, ); + 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()) }, + 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..d3b4e27b82e 100644 --- a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts +++ b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts @@ -316,17 +316,17 @@ 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) => ({ - addressOrDenom: quote[0], - amount: BigInt(quote[1].toString()), - })); + const tokenFeeQuotes: Quote[] = feeQuotes.map( + (quote: { 0: string; 1: any }) => ({ + addressOrDenom: quote[0], + amount: BigInt(quote[1].toString()), + }), + ); // Because the amount is added on the fees, we need to subtract it from the actual fees const tokenFeeQuote: Quote | undefined = @@ -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: { token: string; amount: any }) => ({ igpQuote: { addressOrDenom: quote.token === ethersConstants.AddressZero ? undefined : quote.token, diff --git a/typescript/sdk/src/warp/WarpCore.test.ts b/typescript/sdk/src/warp/WarpCore.test.ts index 4ab9d60a7c2..de030ef9b5a 100644 --- a/typescript/sdk/src/warp/WarpCore.test.ts +++ b/typescript/sdk/src/warp/WarpCore.test.ts @@ -382,6 +382,240 @@ describe('WarpCore', () => { 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('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('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 eae8ea972fa..f98e62e9fb8 100644 --- a/typescript/sdk/src/warp/WarpCore.ts +++ b/typescript/sdk/src/warp/WarpCore.ts @@ -138,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; @@ -162,18 +164,36 @@ 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)) { + assert( + destinationToken?.addressOrDenom, + 'Destination token missing addressOrDenom', + ); + const multiCollateralAdapter = originToken.getHypAdapter( + this.multiProvider, + destinationName, + ) as EvmHypMultiCollateralAdapter; + quote = await multiCollateralAdapter.quoteTransferRemoteToGas({ + destination: destinationDomainId, + recipient, + amount, + targetRouter: destinationToken.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; @@ -225,6 +245,7 @@ export class WarpCore { senderPubKey, interchainFee, tokenFeeQuote, + destinationToken, }: { originToken: IToken; destination: ChainNameOrId; @@ -232,6 +253,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( @@ -274,6 +296,7 @@ export class WarpCore { recipient, interchainFee, tokenFeeQuote, + destinationToken, }); // Starknet does not support gas estimation without starknet account @@ -331,6 +354,7 @@ export class WarpCore { senderPubKey, interchainFee, tokenFeeQuote, + destinationToken, }: { originToken: IToken; destination: ChainNameOrId; @@ -338,6 +362,7 @@ export class WarpCore { senderPubKey?: HexString; interchainFee?: TokenAmount; tokenFeeQuote?: TokenAmount; + destinationToken?: IToken; }): Promise { const originMetadata = this.multiProvider.getChainMetadata( originToken.chainName, @@ -357,6 +382,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 @@ -376,6 +402,7 @@ export class WarpCore { recipient, interchainFee, tokenFeeQuote, + destinationToken, }: { originTokenAmount: TokenAmount; destination: ChainNameOrId; @@ -383,6 +410,7 @@ export class WarpCore { recipient: Address; interchainFee?: TokenAmount; tokenFeeQuote?: TokenAmount; + destinationToken?: IToken; }): Promise> { // Check if this is a MultiCollateral transfer if ( @@ -413,6 +441,7 @@ export class WarpCore { destination, sender, recipient, + destinationToken, }); interchainFee = transferFee.igpQuote; tokenFeeQuote = transferFee.tokenFeeQuote; @@ -553,9 +582,8 @@ export class WarpCore { /** * Executes a MultiCollateral transfer between different collateral routers. - * - * For cross-chain: calls transferRemoteTo with the destination router address. - * For same-chain: calls localTransferTo with the destination router address. + * 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, @@ -590,16 +618,25 @@ export class WarpCore { destinationName, ) as EvmHypMultiCollateralAdapter; + const transferQuote = await adapter.quoteTransferRemoteToGas({ + destination: this.multiProvider.getDomainId(destination), + recipient, + amount, + targetRouter: destinationToken.addressOrDenom!, + }); + const tokenFeeAmount = transferQuote.tokenFeeQuote?.amount ?? 0n; + const totalDebit = amount + tokenFeeAmount; + // Check approval const isApproveRequired = await adapter.isApproveRequired( sender, originToken.addressOrDenom!, - amount, + totalDebit, ); if (isApproveRequired) { const approveTxReq = await adapter.populateApproveTx({ - weiAmountOrId: amount, + weiAmountOrId: totalDebit, recipient: originToken.addressOrDenom!, }); transactions.push({ @@ -609,42 +646,21 @@ export class WarpCore { } as WarpTypedTransaction); } - const isSameChain = originToken.chainName === destinationName; - - if (isSameChain) { - // Same-chain swap via localTransferTo - this.logger.debug( - `MultiCollateral: same-chain swap ${originToken.symbol} -> ${destinationToken.symbol}`, - ); - const txReq = await adapter.populateLocalTransferToTx({ - targetRouter: destinationToken.addressOrDenom!, - recipient, - amount, - }); - transactions.push({ - category: WarpTxCategory.Transfer, - type: providerType, - transaction: txReq, - } as WarpTypedTransaction); - } else { - // Cross-chain transfer via transferRemoteTo - this.logger.debug( - `MultiCollateral: cross-chain ${originToken.symbol} (${originToken.chainName}) -> ${destinationToken.symbol} (${destinationName})`, - ); - const destinationDomainId = this.multiProvider.getDomainId(destination); + // 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: destinationToken.addressOrDenom!, - }); - transactions.push({ - category: WarpTxCategory.Transfer, - type: providerType, - transaction: txReq, - } as WarpTypedTransaction); - } + const txReq = await adapter.populateTransferRemoteToTx({ + destination: destinationDomainId, + recipient, + amount, + targetRouter: destinationToken.addressOrDenom!, + }); + transactions.push({ + category: WarpTxCategory.Transfer, + type: providerType, + transaction: txReq, + } as WarpTypedTransaction); return transactions; } @@ -658,12 +674,14 @@ 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'); @@ -676,6 +694,8 @@ export class WarpCore { destination, destinationToken: destinationToken!, recipient, + sender, + senderPubKey, }); } @@ -713,30 +733,25 @@ export class WarpCore { destination, destinationToken, recipient, + sender, + senderPubKey, }: { originTokenAmount: TokenAmount; destination: ChainNameOrId; destinationToken: IToken; recipient: Address; + sender: Address; + senderPubKey?: HexString; }): Promise { const { token: originToken } = originTokenAmount; const destinationName = this.multiProvider.getChainName(destination); - const isSameChain = originToken.chainName === destinationName; const originMetadata = this.multiProvider.getChainMetadata( originToken.chainName, ); const localGasToken = Token.FromChainMetadataNativeToken(originMetadata); - if (isSameChain) { - return { - interchainQuote: localGasToken.amount(0n), - localQuote: localGasToken.amount(0n), - tokenFeeQuote: undefined, - }; - } - - // Cross-chain: quote from contract + // Quote from contract (works for both same-chain and cross-chain) assert( originToken.collateralAddressOrDenom, 'Origin token missing collateralAddressOrDenom', @@ -752,17 +767,41 @@ export class WarpCore { ) as EvmHypMultiCollateralAdapter; const destinationDomainId = this.multiProvider.getDomainId(destination); - const { igpQuote } = await adapter.quoteTransferRemoteToGas({ - destination: destinationDomainId, - recipient, - amount: originTokenAmount.amount, - targetRouter: destinationToken.addressOrDenom!, + const { igpQuote, tokenFeeQuote: rawTokenFeeQuote } = + await adapter.quoteTransferRemoteToGas({ + destination: destinationDomainId, + recipient, + amount: originTokenAmount.amount, + targetRouter: destinationToken.addressOrDenom!, + }); + + let tokenFeeQuote: TokenAmount | undefined; + if (rawTokenFeeQuote?.amount) { + if ( + !rawTokenFeeQuote.addressOrDenom || + isZeroishAddress(rawTokenFeeQuote.addressOrDenom) + ) { + tokenFeeQuote = localGasToken.amount(rawTokenFeeQuote.amount); + } else { + tokenFeeQuote = originToken.amount(rawTokenFeeQuote.amount); + } + } + + const interchainQuote = localGasToken.amount(igpQuote.amount); + const localQuote = await this.getLocalTransferFeeAmount({ + originToken, + destination, + sender, + senderPubKey, + interchainFee: interchainQuote, + tokenFeeQuote, + destinationToken, }); return { - interchainQuote: localGasToken.amount(igpQuote.amount), - localQuote: localGasToken.amount(0n), - tokenFeeQuote: undefined, + interchainQuote, + localQuote, + tokenFeeQuote, }; } @@ -777,6 +816,7 @@ export class WarpCore { sender, senderPubKey, feeEstimate, + destinationToken, }: { balance: TokenAmount; destination: ChainNameOrId; @@ -784,6 +824,7 @@ export class WarpCore { sender: Address; senderPubKey?: HexString; feeEstimate?: WarpCoreFeeEstimate; + destinationToken?: IToken; }): Promise { const originToken = balance.token; @@ -794,6 +835,7 @@ export class WarpCore { recipient, sender, senderPubKey, + destinationToken, }); } const { localQuote, interchainQuote, tokenFeeQuote } = feeEstimate; @@ -813,6 +855,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 @@ -844,31 +887,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(), ); @@ -876,13 +928,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, }); @@ -936,12 +988,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, @@ -952,22 +1006,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; @@ -981,6 +1055,7 @@ export class WarpCore { sender, recipient, senderPubKey, + resolvedDestinationToken, ); if (balancesError) return balancesError; @@ -1051,6 +1126,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(); @@ -1061,21 +1137,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 = resolvedDestinationToken.amount( convertDecimalsToIntegerString( originToken.decimals, - destinationToken.decimals, + resolvedDestinationToken.decimals, minDestinationTransferAmount.toString(), ), ); @@ -1100,6 +1179,7 @@ export class WarpCore { sender: Address, recipient: Address, senderPubKey?: HexString, + destinationToken?: IToken, ): Promise | null> { const { token: originToken, amount } = originTokenAmount; @@ -1121,6 +1201,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( @@ -1153,6 +1234,7 @@ export class WarpCore { senderPubKey, interchainFee: interchainQuote, tokenFeeQuote, + destinationToken, }); const feeEstimate = { interchainQuote, localQuote }; @@ -1165,6 +1247,7 @@ export class WarpCore { sender, senderPubKey, feeEstimate, + destinationToken, }); if (amount > maxTransfer.amount) { return { amount: 'Insufficient balance for gas and transfer' }; @@ -1179,10 +1262,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) { @@ -1197,35 +1282,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; @@ -1237,16 +1326,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(), ); @@ -1284,6 +1373,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 */ From da848b0063501f67ce0bef4f044dc83e368fd9f1 Mon Sep 17 00:00:00 2001 From: nambrot Date: Sat, 28 Feb 2026 13:28:55 -0500 Subject: [PATCH 03/26] feat: add pending-transfer and inventory metrics to warp monitor --- typescript/warp-monitor/src/explorer.test.ts | 130 +++++++++ typescript/warp-monitor/src/explorer.ts | 250 +++++++++++++++++ typescript/warp-monitor/src/metrics.test.ts | 59 ++++ typescript/warp-monitor/src/metrics.ts | 123 ++++++++- typescript/warp-monitor/src/monitor.ts | 267 ++++++++++++++++++- typescript/warp-monitor/src/service.ts | 21 ++ typescript/warp-monitor/src/types.test.ts | 13 + typescript/warp-monitor/src/types.ts | 3 + 8 files changed, 859 insertions(+), 7 deletions(-) create mode 100644 typescript/warp-monitor/src/explorer.test.ts create mode 100644 typescript/warp-monitor/src/explorer.ts diff --git a/typescript/warp-monitor/src/explorer.test.ts b/typescript/warp-monitor/src/explorer.test.ts new file mode 100644 index 00000000000..537f687b081 --- /dev/null +++ b/typescript/warp-monitor/src/explorer.test.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { rootLogger } from '@hyperlane-xyz/utils'; + +import { + ExplorerPendingTransfersClient, + canonical18ToTokenBaseUnits, + 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 canonical18 amount to token base units', () => { + const canonicalAmount = 1234567890000000000n; + expect(canonical18ToTokenBaseUnits(canonicalAmount, 18)).to.equal( + canonicalAmount, + ); + expect(canonical18ToTokenBaseUnits(canonicalAmount, 6)).to.equal( + 1234567n, + ); + expect(canonical18ToTokenBaseUnits(1n, 20)).to.equal(100n); + }); + }); + + 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, + 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'); + }); +}); diff --git a/typescript/warp-monitor/src/explorer.ts b/typescript/warp-monitor/src/explorer.ts new file mode 100644 index 00000000000..01616b35464 --- /dev/null +++ b/typescript/warp-monitor/src/explorer.ts @@ -0,0 +1,250 @@ +import type { Logger } from 'pino'; + +import type { ChainName, Token } from '@hyperlane-xyz/sdk'; +import { + bytes32ToAddress, + isValidAddressEvm, + isZeroishAddress, + parseWarpRouteMessage, +} from '@hyperlane-xyz/utils'; + +const CANONICAL_DECIMALS = 18; + +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; + 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 canonical18ToTokenBaseUnits( + amountCanonical18: bigint, + tokenDecimals: number, +): bigint { + if (tokenDecimals === CANONICAL_DECIMALS) return amountCanonical18; + if (tokenDecimals < CANONICAL_DECIMALS) { + const divisor = 10n ** BigInt(CANONICAL_DECIMALS - tokenDecimals); + return amountCanonical18 / divisor; + } + + const multiplier = 10n ** BigInt(tokenDecimals - CANONICAL_DECIMALS); + return amountCanonical18 * multiplier; +} + +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 as Error).message, + }, + '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: canonical18ToTokenBaseUnits( + parsedMessage.amount, + node.tokenDecimals, + ), + 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 response = await fetch(this.apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }); + + 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 = await response.json(); + return (payload?.data?.message_view ?? []) as ExplorerMessageRow[]; + } +} diff --git a/typescript/warp-monitor/src/metrics.test.ts b/typescript/warp-monitor/src/metrics.test.ts index 1699329fafc..60f056e10c3 100644 --- a/typescript/warp-monitor/src/metrics.test.ts +++ b/typescript/warp-monitor/src/metrics.test.ts @@ -10,7 +10,11 @@ import { TokenStandard } from '@hyperlane-xyz/sdk'; import { metricsRegister, + resetInventoryBalanceMetrics, + resetPendingDestinationMetrics, + updateInventoryBalanceMetrics, updateNativeWalletBalanceMetrics, + updatePendingDestinationMetrics, updateTokenBalanceMetrics, updateXERC20LimitsMetrics, } from './metrics.js'; @@ -301,4 +305,59 @@ describe('Warp Monitor Metrics', () => { ); }); }); + + describe('pending destination metrics', () => { + it('should record pending amount/count/age and projected deficit', 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, + projectedDeficit: 23.45, + }); + + 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('hyperlane_warp_route_projected_deficit'); + expect(metrics).to.include('node_id="USDC|base|0xrouter"'); + expect(metrics).to.include('token_symbol="USDC"'); + }); + }); + + 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..15b26cef646 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,78 @@ 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; + 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 +166,52 @@ 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); + 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.ts b/typescript/warp-monitor/src/monitor.ts index 42cf31d836a..36fb3a55652 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,36 @@ import { tryFn, } from '@hyperlane-xyz/utils'; +import { + ExplorerPendingTransfersClient, + type RouterNodeMetadata, +} from './explorer.js'; import { metricsRegister, + resetInventoryBalanceMetrics, + resetPendingDestinationMetrics, + updateInventoryBalanceMetrics, updateManagedLockboxBalanceMetrics, updateNativeWalletBalanceMetrics, + updatePendingDestinationMetrics, 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 +71,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 +121,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 +132,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 +146,10 @@ export class WarpMonitor { chainMetadata, warpRouteId, coingeckoApiKey, + routerNodes, + pendingTransfersClient, + explorerQueryLimit, + inventoryAddress, ); } @@ -120,7 +160,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 +188,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 +199,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 +226,141 @@ 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) { + logger.error( + { + error: (error as 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; + const projectedDeficitBaseUnits = + aggregate.amountBaseUnits > routerCollateral + ? aggregate.amountBaseUnits - routerCollateral + : 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, + 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) => { + let inventoryBalance = 0n; + + await tryFn( + async () => { + const adapter = node.token.getAdapter(warpCore.multiProvider); + inventoryBalance = await adapter.getBalance(inventoryAddress); + }, + `Reading inventory balance for ${node.nodeId}`, + logger, + ); + + 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), + }); + }), + ); + } + // Updates the metrics for a single token in a warp route. private async updateTokenMetrics( warpCore: WarpCore, @@ -170,21 +368,35 @@ 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 +452,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 +471,7 @@ export class WarpMonitor { 'Invalid deploy config type for xERC20 token', ); await Promise.all(promises); - return; + return collateralSnapshot; } const extraLockboxes = @@ -323,6 +535,49 @@ 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, + 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..a8b13366d38 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 = parseInt(process.env.EXPLORER_QUERY_LIMIT, 10); + if (isNaN(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; } From 53d9a5c67f0874d3ed1b538ed89a5edfb7a94329 Mon Sep 17 00:00:00 2001 From: nambrot Date: Sat, 28 Feb 2026 14:24:16 -0500 Subject: [PATCH 04/26] fix: align sdk multicollateral imports and defer routing-fee mutations --- pnpm-lock.yaml | 7 +- typescript/sdk/package.json | 1 + .../fee/EvmTokenFeeDeployer.hardhat-test.ts | 3 - typescript/sdk/src/fee/EvmTokenFeeDeployer.ts | 25 +- typescript/sdk/src/fee/EvmTokenFeeModule.ts | 272 +++++------------- typescript/sdk/src/fee/EvmTokenFeeReader.ts | 50 +--- typescript/sdk/src/fee/types.ts | 22 -- typescript/sdk/src/token/EvmWarpModule.ts | 2 +- .../sdk/src/token/EvmWarpRouteReader.ts | 2 +- .../adapters/EvmMultiCollateralAdapter.ts | 4 +- typescript/sdk/src/token/contracts.ts | 2 +- typescript/sdk/src/token/deploy.ts | 4 +- 12 files changed, 91 insertions(+), 303 deletions(-) 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/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/fee/EvmTokenFeeDeployer.hardhat-test.ts b/typescript/sdk/src/fee/EvmTokenFeeDeployer.hardhat-test.ts index 17f12cd97db..5a4ab11c46a 100644 --- a/typescript/sdk/src/fee/EvmTokenFeeDeployer.hardhat-test.ts +++ b/typescript/sdk/src/fee/EvmTokenFeeDeployer.hardhat-test.ts @@ -1,6 +1,5 @@ import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers.js'; import { expect } from 'chai'; -import { constants } from 'ethers'; import hre from 'hardhat'; import { ERC20Test, ERC20Test__factory } from '@hyperlane-xyz/core'; @@ -186,7 +185,6 @@ describe('EvmTokenFeeDeployer', () => { // Read the actual address of the deployed routing fee contract const actualLinearFeeAddress = await routingFeeContract.feeContracts( multiProvider.getChainId(TestChainName.test2), - constants.HashZero, ); expect(actualLinearFeeAddress).to.equal( @@ -226,7 +224,6 @@ describe('EvmTokenFeeDeployer', () => { const actualLinearFeeAddress = await routingFeeContract.feeContracts( multiProvider.getChainId(TestChainName.test2), - constants.HashZero, ); expect(actualLinearFeeAddress).to.equal(linearFeeContract.address); expect(await linearFeeContract.owner()).to.equal(otherSigner.address); diff --git a/typescript/sdk/src/fee/EvmTokenFeeDeployer.ts b/typescript/sdk/src/fee/EvmTokenFeeDeployer.ts index eb8374e500e..7e62e83aea3 100644 --- a/typescript/sdk/src/fee/EvmTokenFeeDeployer.ts +++ b/typescript/sdk/src/fee/EvmTokenFeeDeployer.ts @@ -133,30 +133,7 @@ export class EvmTokenFeeDeployer extends HyperlaneDeployer< this.multiProvider.getTransactionOverrides(chain), ), ); - subFeeContracts[destinationChain] = - deployedFeeContract as unknown as BaseFee; - } - } - - if (config.routerFeeContracts) { - for (const [destinationChain, routerMap] of Object.entries( - config.routerFeeContracts, - )) { - for (const [routerBytes32, feeConfig] of Object.entries(routerMap)) { - const deployedFeeContract = await this.deployFee(chain, feeConfig); - - await this.multiProvider.handleTx( - chain, - routingFee.setRouterFeeContract( - this.multiProvider.getChainId(destinationChain), - routerBytes32, - deployedFeeContract.address, - this.multiProvider.getTransactionOverrides(chain), - ), - ); - subFeeContracts[`router:${destinationChain}:${routerBytes32}`] = - deployedFeeContract as unknown as BaseFee; - } + subFeeContracts[destinationChain] = deployedFeeContract; } } diff --git a/typescript/sdk/src/fee/EvmTokenFeeModule.ts b/typescript/sdk/src/fee/EvmTokenFeeModule.ts index d6fdc652303..d4893804f11 100644 --- a/typescript/sdk/src/fee/EvmTokenFeeModule.ts +++ b/typescript/sdk/src/fee/EvmTokenFeeModule.ts @@ -53,32 +53,21 @@ function resolveTokenForFeeConfig( config: TokenFeeConfigInput, token: Address, ): ResolvedTokenFeeConfigInput { - if (config.type === TokenFeeType.RoutingFee) { - const resolved: ResolvedTokenFeeConfigInput = { ...config, token }; - if ('feeContracts' in config && config.feeContracts) { - resolved.feeContracts = Object.fromEntries( + if ( + config.type === TokenFeeType.RoutingFee && + 'feeContracts' in config && + config.feeContracts + ) { + return { + ...config, + token, + feeContracts: Object.fromEntries( Object.entries(config.feeContracts).map(([chain, subFee]) => [ chain, resolveTokenForFeeConfig(subFee, token), ]), - ); - } - if ('routerFeeContracts' in config && config.routerFeeContracts) { - resolved.routerFeeContracts = Object.fromEntries( - Object.entries(config.routerFeeContracts).map(([chain, routerMap]) => [ - chain, - Object.fromEntries( - Object.entries( - routerMap as Record, - ).map(([routerBytes32, subFee]) => [ - routerBytes32, - resolveTokenForFeeConfig(subFee, token), - ]), - ), - ]), - ); - } - return resolved; + ), + }; } return { ...config, token }; } @@ -211,8 +200,6 @@ export class EvmTokenFeeModule extends HyperlaneModule< const { token, owner } = config; const inputFeeContracts = 'feeContracts' in config ? config.feeContracts : undefined; - const inputRouterFeeContracts = - 'routerFeeContracts' in config ? config.routerFeeContracts : undefined; const feeContracts = inputFeeContracts ? await promiseObjAll( @@ -233,33 +220,6 @@ export class EvmTokenFeeModule extends HyperlaneModule< ) : undefined; - let routerFeeContracts: - | Record> - | undefined; - if (inputRouterFeeContracts) { - routerFeeContracts = {}; - for (const [chain, routerMap] of Object.entries( - inputRouterFeeContracts, - )) { - routerFeeContracts[chain] = await promiseObjAll( - objMap( - routerMap as Record, - async (_, innerConfig) => { - const resolvedInnerConfig: ResolvedTokenFeeConfigInput = { - ...innerConfig, - token: innerConfig.token ?? token, - }; - return EvmTokenFeeModule.expandConfig({ - config: resolvedInnerConfig, - multiProvider, - chainName, - }); - }, - ), - ); - } - } - intermediaryConfig = { type: TokenFeeType.RoutingFee, token, @@ -267,7 +227,6 @@ export class EvmTokenFeeModule extends HyperlaneModule< maxFee: constants.MaxUint256.toBigInt(), halfAmount: constants.MaxUint256.toBigInt(), feeContracts, - routerFeeContracts, }; } else { // Progressive/Regressive fees @@ -336,32 +295,18 @@ export class EvmTokenFeeModule extends HyperlaneModule< let updateTransactions: AnnotatedEV5Transaction[] = []; - // Derive routingDestinations and routersByDestination from target config if not provided + // Derive routingDestinations from target config if not provided // This ensures we read all sub-fee configs that need to be compared/updated let effectiveParams = params; - if (targetConfig.type === TokenFeeType.RoutingFee) { - if ( - !params?.routingDestinations && - !isNullish(targetConfig.feeContracts) - ) { - const routingDestinations = Object.keys(targetConfig.feeContracts).map( - (chainName) => this.multiProvider.getDomainId(chainName), - ); - effectiveParams = { ...effectiveParams, routingDestinations }; - } - if ( - !params?.routersByDestination && - !isNullish(targetConfig.routerFeeContracts) - ) { - const routersByDestination: Record = {}; - for (const [chainName, routerMap] of Object.entries( - targetConfig.routerFeeContracts, - )) { - const domainId = this.multiProvider.getDomainId(chainName); - routersByDestination[domainId] = Object.keys(routerMap); - } - effectiveParams = { ...effectiveParams, routersByDestination }; - } + if ( + !params?.routingDestinations && + targetConfig.type === TokenFeeType.RoutingFee && + !isNullish(targetConfig.feeContracts) + ) { + const routingDestinations = Object.keys(targetConfig.feeContracts).map( + (chainName) => this.multiProvider.getDomainId(chainName), + ); + effectiveParams = { ...params, routingDestinations }; } const actualConfig = await this.read(effectiveParams); @@ -437,31 +382,62 @@ export class EvmTokenFeeModule extends HyperlaneModule< private async updateRoutingFee(targetConfig: DerivedRoutingFeeConfig) { const updateTransactions: AnnotatedEV5Transaction[] = []; + if (!targetConfig.feeContracts) return []; const currentRoutingAddress = this.args.addresses.deployedFee; - - if (targetConfig.feeContracts) { - for (const [chainName, config] of Object.entries( - targetConfig.feeContracts, - )) { - const address = config.address; - - let subFeeModule: EvmTokenFeeModule; - let deployedSubFee: string; - - if (!address) { - // Sub-fee contract doesn't exist yet, deploy a new one - this.logger.info( - `No existing sub-fee contract for ${chainName}, deploying new one`, - ); - subFeeModule = await EvmTokenFeeModule.create({ - multiProvider: this.multiProvider, + for (const [chainName, config] of Object.entries( + targetConfig.feeContracts, + )) { + const address = config.address; + + let subFeeModule: EvmTokenFeeModule; + let deployedSubFee: string; + + if (!address) { + // Sub-fee contract doesn't exist yet, deploy a new one + this.logger.info( + `No existing sub-fee contract for ${chainName}, deploying new one`, + ); + subFeeModule = await EvmTokenFeeModule.create({ + multiProvider: this.multiProvider, + chain: this.chainName, + config, + contractVerifier: this.contractVerifier, + }); + deployedSubFee = subFeeModule.serialize().deployedFee; + + const annotation = `New sub fee contract deployed. Setting contract for ${chainName} to ${deployedSubFee}`; + this.logger.debug(annotation); + updateTransactions.push({ + annotation: annotation, + chainId: this.chainId, + to: currentRoutingAddress, + data: RoutingFee__factory.createInterface().encodeFunctionData( + 'setFeeContract(uint32,address)', + [this.multiProvider.getDomainId(chainName), deployedSubFee], + ), + }); + } else { + // Update existing sub-fee contract + subFeeModule = new EvmTokenFeeModule( + this.multiProvider, + { + addresses: { + deployedFee: address, + }, chain: this.chainName, config, - contractVerifier: this.contractVerifier, - }); - deployedSubFee = subFeeModule.serialize().deployedFee; + }, + this.contractVerifier, + ); + const subFeeUpdateTransactions = await subFeeModule.update(config, { + address, + }); + deployedSubFee = subFeeModule.serialize().deployedFee; + + updateTransactions.push(...subFeeUpdateTransactions); - const annotation = `New sub fee contract deployed. Setting contract for ${chainName} to ${deployedSubFee}`; + if (!eqAddress(deployedSubFee, address)) { + const annotation = `Sub fee contract redeployed on chain ${this.chainName}. Updating fee contract for destination ${chainName} to ${deployedSubFee}`; this.logger.debug(annotation); updateTransactions.push({ annotation: annotation, @@ -472,108 +448,6 @@ export class EvmTokenFeeModule extends HyperlaneModule< [this.multiProvider.getDomainId(chainName), deployedSubFee], ), }); - } else { - // Update existing sub-fee contract - subFeeModule = new EvmTokenFeeModule( - this.multiProvider, - { - addresses: { - deployedFee: address, - }, - chain: this.chainName, - config, - }, - this.contractVerifier, - ); - const subFeeUpdateTransactions = await subFeeModule.update(config, { - address, - }); - deployedSubFee = subFeeModule.serialize().deployedFee; - - updateTransactions.push(...subFeeUpdateTransactions); - - if (!eqAddress(deployedSubFee, address)) { - const annotation = `Sub fee contract redeployed on chain ${this.chainName}. Updating fee contract for destination ${chainName} to ${deployedSubFee}`; - this.logger.debug(annotation); - updateTransactions.push({ - annotation: annotation, - chainId: this.chainId, - to: currentRoutingAddress, - data: RoutingFee__factory.createInterface().encodeFunctionData( - 'setFeeContract(uint32,address)', - [this.multiProvider.getDomainId(chainName), deployedSubFee], - ), - }); - } - } - } - } - - if (targetConfig.routerFeeContracts) { - for (const [chainName, routerMap] of Object.entries( - targetConfig.routerFeeContracts, - )) { - const destinationDomain = this.multiProvider.getDomainId(chainName); - for (const [routerBytes32, config] of Object.entries(routerMap)) { - const address = config.address; - - let subFeeModule: EvmTokenFeeModule; - let deployedSubFee: string; - - if (!address) { - this.logger.info( - `No existing router fee contract for ${chainName}/${routerBytes32}, deploying new one`, - ); - subFeeModule = await EvmTokenFeeModule.create({ - multiProvider: this.multiProvider, - chain: this.chainName, - config, - contractVerifier: this.contractVerifier, - }); - deployedSubFee = subFeeModule.serialize().deployedFee; - - const annotation = `New router fee contract deployed. Setting contract for ${chainName}/${routerBytes32} to ${deployedSubFee}`; - this.logger.debug(annotation); - updateTransactions.push({ - annotation, - chainId: this.chainId, - to: currentRoutingAddress, - data: RoutingFee__factory.createInterface().encodeFunctionData( - 'setRouterFeeContract', - [destinationDomain, routerBytes32, deployedSubFee], - ), - }); - } else { - subFeeModule = new EvmTokenFeeModule( - this.multiProvider, - { - addresses: { deployedFee: address }, - chain: this.chainName, - config, - }, - this.contractVerifier, - ); - const subFeeUpdateTransactions = await subFeeModule.update(config, { - address, - }); - deployedSubFee = subFeeModule.serialize().deployedFee; - - updateTransactions.push(...subFeeUpdateTransactions); - - if (!eqAddress(deployedSubFee, address)) { - const annotation = `Router fee contract redeployed on chain ${this.chainName}. Updating for ${chainName}/${routerBytes32} to ${deployedSubFee}`; - this.logger.debug(annotation); - updateTransactions.push({ - annotation, - chainId: this.chainId, - to: currentRoutingAddress, - data: RoutingFee__factory.createInterface().encodeFunctionData( - 'setRouterFeeContract', - [destinationDomain, routerBytes32, deployedSubFee], - ), - }); - } - } } } } diff --git a/typescript/sdk/src/fee/EvmTokenFeeReader.ts b/typescript/sdk/src/fee/EvmTokenFeeReader.ts index 80a132c67a2..0a53619dcc2 100644 --- a/typescript/sdk/src/fee/EvmTokenFeeReader.ts +++ b/typescript/sdk/src/fee/EvmTokenFeeReader.ts @@ -5,7 +5,7 @@ import { LinearFee__factory, RoutingFee__factory, } from '@hyperlane-xyz/core'; -import { Address, WithAddress, addressToBytes32 } from '@hyperlane-xyz/utils'; +import { Address, WithAddress } from '@hyperlane-xyz/utils'; import { MultiProvider } from '../providers/MultiProvider.js'; import { ChainName, ChainNameOrId } from '../types.js'; @@ -28,13 +28,11 @@ export type DerivedTokenFeeConfig = WithAddress; export type DerivedRoutingFeeConfig = WithAddress & { feeContracts: Record; - routerFeeContracts?: Record>; }; export type TokenFeeReaderParams = { address: Address; routingDestinations?: number[]; // Optional: when provided, derives feeContracts - routersByDestination?: Record; // Optional: destination domain -> router addresses, derives routerFeeContracts }; export class EvmTokenFeeReader extends HyperlaneReader { @@ -48,7 +46,7 @@ export class EvmTokenFeeReader extends HyperlaneReader { async deriveTokenFeeConfig( params: TokenFeeReaderParams, ): Promise { - const { address, routingDestinations, routersByDestination } = params; + const { address, routingDestinations } = params; const tokenFee = BaseFee__factory.connect(address, this.provider); let derivedConfig: DerivedTokenFeeConfig; @@ -67,7 +65,6 @@ export class EvmTokenFeeReader extends HyperlaneReader { derivedConfig = await this.deriveRoutingFeeConfig({ address, routingDestinations, - routersByDestination, }); break; default: @@ -117,7 +114,7 @@ export class EvmTokenFeeReader extends HyperlaneReader { private async deriveRoutingFeeConfig( params: TokenFeeReaderParams, ): Promise { - const { address, routingDestinations, routersByDestination } = params; + const { address, routingDestinations } = params; const routingFee = RoutingFee__factory.connect(address, this.provider); const [token, owner, maxFee, halfAmount] = await Promise.all([ routingFee.token(), @@ -134,10 +131,7 @@ export class EvmTokenFeeReader extends HyperlaneReader { if (routingDestinations) await Promise.all( routingDestinations.map(async (destination) => { - const subFeeAddress = await routingFee.feeContracts( - destination, - constants.HashZero, - ); + const subFeeAddress = await routingFee.feeContracts(destination); if (subFeeAddress === constants.AddressZero) return; const chainName = this.multiProvider.getChainName(destination); feeContracts[chainName] = await this.deriveTokenFeeConfig({ @@ -149,39 +143,6 @@ export class EvmTokenFeeReader extends HyperlaneReader { }); }), ); - - const routerFeeContracts: Record< - ChainName, - Record - > = {}; - if (routersByDestination) { - await Promise.all( - Object.entries(routersByDestination).map( - async ([destStr, routerAddrs]) => { - const destination = Number(destStr); - const chainName = this.multiProvider.getChainName(destination); - const perRouter: Record = {}; - await Promise.all( - routerAddrs.map(async (routerAddr) => { - const routerBytes32 = addressToBytes32(routerAddr); - const subFeeAddress = await routingFee.feeContracts( - destination, - routerBytes32, - ); - if (subFeeAddress === constants.AddressZero) return; - perRouter[routerBytes32] = await this.deriveTokenFeeConfig({ - address: subFeeAddress, - }); - }), - ); - if (Object.keys(perRouter).length > 0) { - routerFeeContracts[chainName] = perRouter; - } - }, - ), - ); - } - return { type: TokenFeeType.RoutingFee, maxFee: maxFeeBn, @@ -190,9 +151,6 @@ export class EvmTokenFeeReader extends HyperlaneReader { token, owner, feeContracts, - ...(Object.keys(routerFeeContracts).length > 0 && { - routerFeeContracts, - }), }; } diff --git a/typescript/sdk/src/fee/types.ts b/typescript/sdk/src/fee/types.ts index 4dc598f0c76..4ec058bec2c 100644 --- a/typescript/sdk/src/fee/types.ts +++ b/typescript/sdk/src/fee/types.ts @@ -155,15 +155,6 @@ export const RoutingFeeConfigSchema = BaseFeeConfigSchema.extend({ z.lazy((): z.ZodSchema => TokenFeeConfigSchema), ) .optional(), // Destination -> Fee - routerFeeContracts: z - .record( - ZChainName, - z.record( - ZHash, - z.lazy((): z.ZodSchema => TokenFeeConfigSchema), - ), - ) - .optional(), // Destination -> TargetRouter (bytes32) -> Fee maxFee: ZBigNumberish.optional(), halfAmount: ZBigNumberish.optional(), }); @@ -178,15 +169,6 @@ export const RoutingFeeInputConfigSchema = BaseFeeConfigInputSchema.extend({ z.lazy((): z.ZodSchema => TokenFeeConfigInputSchema), ) .optional(), - routerFeeContracts: z - .record( - ZChainName, - z.record( - ZHash, - z.lazy((): z.ZodSchema => TokenFeeConfigInputSchema), - ), - ) - .optional(), }); export type RoutingFeeInputConfig = z.infer; @@ -217,8 +199,4 @@ export type ResolvedTokenFeeConfigInput = TokenFeeConfigInput & { export type ResolvedRoutingFeeConfigInput = RoutingFeeInputConfig & { token: string; feeContracts?: Record; - routerFeeContracts?: Record< - string, - Record - >; }; diff --git a/typescript/sdk/src/token/EvmWarpModule.ts b/typescript/sdk/src/token/EvmWarpModule.ts index 4646a003b36..885e4be3955 100644 --- a/typescript/sdk/src/token/EvmWarpModule.ts +++ b/typescript/sdk/src/token/EvmWarpModule.ts @@ -9,11 +9,11 @@ import { IERC20__factory, MailboxClient__factory, MovableCollateralRouter__factory, - MultiCollateral__factory, ProxyAdmin__factory, 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, diff --git a/typescript/sdk/src/token/EvmWarpRouteReader.ts b/typescript/sdk/src/token/EvmWarpRouteReader.ts index 360b61fb005..ee67e6139b5 100644 --- a/typescript/sdk/src/token/EvmWarpRouteReader.ts +++ b/typescript/sdk/src/token/EvmWarpRouteReader.ts @@ -17,7 +17,6 @@ import { IWETH__factory, IXERC20__factory, MovableCollateralRouter__factory, - MultiCollateral__factory, OpL1NativeTokenBridge__factory, OpL2NativeTokenBridge__factory, Ownable__factory, @@ -28,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, diff --git a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts index bbc7071601b..49fd6cc5e32 100644 --- a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts +++ b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts @@ -1,10 +1,10 @@ import { PopulatedTransaction } from 'ethers'; +import { ERC20__factory } from '@hyperlane-xyz/core'; import { - ERC20__factory, MultiCollateral, MultiCollateral__factory, -} from '@hyperlane-xyz/core'; +} from '@hyperlane-xyz/multicollateral'; import { Address, Domain, diff --git a/typescript/sdk/src/token/contracts.ts b/typescript/sdk/src/token/contracts.ts index 5d01d516bbc..bf93f222f55 100644 --- a/typescript/sdk/src/token/contracts.ts +++ b/typescript/sdk/src/token/contracts.ts @@ -16,12 +16,12 @@ import { HypNative__factory, HypXERC20Lockbox__factory, HypXERC20__factory, - MultiCollateral__factory, OpL1V1NativeTokenBridge__factory, OpL2NativeTokenBridge__factory, TokenBridgeCctpV1__factory, TokenBridgeCctpV2__factory, } from '@hyperlane-xyz/core'; +import { MultiCollateral__factory } from '@hyperlane-xyz/multicollateral'; import { DeployableTokenType, TokenType } from './config.js'; diff --git a/typescript/sdk/src/token/deploy.ts b/typescript/sdk/src/token/deploy.ts index eab6bed2880..1af6c50fb11 100644 --- a/typescript/sdk/src/token/deploy.ts +++ b/typescript/sdk/src/token/deploy.ts @@ -7,7 +7,6 @@ import { GasRouter, IMessageTransmitter__factory, MovableCollateralRouter__factory, - MultiCollateral__factory, OpL1V1NativeTokenBridge__factory, OpL2NativeTokenBridge__factory, PackageVersioned__factory, @@ -15,6 +14,7 @@ import { TokenBridgeCctpV2__factory, TokenRouter, } from '@hyperlane-xyz/core'; +import { MultiCollateral__factory } from '@hyperlane-xyz/multicollateral'; import { Address, ProtocolType, @@ -767,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 unknown as TokenRouter; } } throw new Error('No matching contract found'); From 2a120303cdb5e5b7e8476f464bba92f36c89583c Mon Sep 17 00:00:00 2001 From: nambrot Date: Sat, 28 Feb 2026 15:05:17 -0500 Subject: [PATCH 05/26] refactor(cli): dedupe multicollateral prompt switch case --- typescript/cli/src/config/warp.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/typescript/cli/src/config/warp.ts b/typescript/cli/src/config/warp.ts index db0c049bc45..f59d966d141 100644 --- a/typescript/cli/src/config/warp.ts +++ b/typescript/cli/src/config/warp.ts @@ -250,6 +250,7 @@ export async function createWarpRouteDeployConfig({ case TokenType.XERC20: case TokenType.XERC20Lockbox: case TokenType.collateralFiat: + case TokenType.multiCollateral: result[chain] = { type, owner, @@ -328,17 +329,6 @@ export async function createWarpRouteDeployConfig({ isNft: false, }; break; - case TokenType.multiCollateral: - result[chain] = { - type, - owner, - proxyAdmin, - interchainSecurityModule, - token: await input({ - message: `Enter the existing token address on chain ${chain}`, - }), - }; - break; default: throw new Error(`Token type ${type} is not supported`); } From 82b8dde800aa5ac4d66c5d113e6b2899e92ad22a Mon Sep 17 00:00:00 2001 From: nambrot Date: Sat, 28 Feb 2026 17:41:53 -0500 Subject: [PATCH 06/26] feat(cli): validate warp combine inputs and warn on unenroll --- pnpm-lock.yaml | 1 - solidity/multicollateral/package.json | 5 +- typescript/cli/src/deploy/warp.test.ts | 286 +++++++++++++++++++++++++ typescript/cli/src/deploy/warp.ts | 111 +++++++++- 4 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 typescript/cli/src/deploy/warp.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 347a79000b0..c1e8acd5f97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -507,7 +507,6 @@ importers: '@hyperlane-xyz/core': specifier: workspace:* version: link:.. - devDependencies: '@typechain/ethers-v5': specifier: 11.1.2 version: 11.1.2(@ethersproject/abi@5.8.0)(@ethersproject/providers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=fbb49bf3d1f71d8430767373c9ae33cd36e1aedd8ae9584d00dc90775f804950)(typescript@5.8.3))(typescript@5.8.3) diff --git a/solidity/multicollateral/package.json b/solidity/multicollateral/package.json index cb1466ab113..7ef61244df4 100644 --- a/solidity/multicollateral/package.json +++ b/solidity/multicollateral/package.json @@ -37,13 +37,12 @@ "test:ci": "pnpm test" }, "dependencies": { - "@hyperlane-xyz/core": "workspace:*" - }, - "devDependencies": { + "@hyperlane-xyz/core": "workspace:*", "@typechain/ethers-v5": "catalog:", "typechain": "catalog:", "typescript": "catalog:" }, + "devDependencies": {}, "peerDependencies": { "@ethersproject/abi": "*", "@ethersproject/providers": "*" diff --git a/typescript/cli/src/deploy/warp.test.ts b/typescript/cli/src/deploy/warp.test.ts new file mode 100644 index 00000000000..198dd08c350 --- /dev/null +++ b/typescript/cli/src/deploy/warp.test.ts @@ -0,0 +1,286 @@ +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; +}) { + 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, + 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, + 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 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, + 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, + 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, + 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, + 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"', + ); + }); +}); diff --git a/typescript/cli/src/deploy/warp.ts b/typescript/cli/src/deploy/warp.ts index 01f85ca0781..b2d6363050a 100644 --- a/typescript/cli/src/deploy/warp.ts +++ b/typescript/cli/src/deploy/warp.ts @@ -38,6 +38,7 @@ import { WarpCoreConfigSchema, type WarpRouteDeployConfigMailboxRequired, WarpRouteDeployConfigSchema, + TokenStandard, altVmChainLookup, enrollCrossChainRouters, executeWarpDeploy, @@ -49,6 +50,7 @@ import { isCollateralTokenConfig, isMultiCollateralTokenConfig, isXERC20TokenConfig, + normalizeScale, splitWarpCoreAndExtendedConfigs, tokenTypeToStandard, } from '@hyperlane-xyz/sdk'; @@ -1256,6 +1258,89 @@ export async function getSubmitterByStrategy({ }; } +type CombineRouteConfig = { + id: string; + coreConfig: WarpCoreConfig; + deployConfig: WarpRouteDeployConfigMailboxRequired; +}; + +type CanonicalWholeTokenRatio = { + numerator: bigint; + denominator: bigint; +}; + +function formatScaleForLogs( + scale: WarpCoreConfig['tokens'][number]['scale'], +): string { + if (!scale) return '1'; + if (typeof scale === 'number') return scale.toString(); + return `${scale.numerator.toString()}/${scale.denominator.toString()}`; +} + +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. @@ -1272,11 +1357,7 @@ export async function runWarpRouteCombine({ assert(routeIds.length >= 2, 'At least 2 route IDs are required to combine'); // 1. Read each route's WarpCoreConfig and deploy config - const routes: Array<{ - id: string; - coreConfig: WarpCoreConfig; - deployConfig: WarpRouteDeployConfigMailboxRequired; - }> = []; + const routes: CombineRouteConfig[] = []; for (const id of routeIds) { const coreConfig = await context.registry.getWarpRoute(id); @@ -1290,6 +1371,8 @@ export async function runWarpRouteCombine({ }); } + 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)) { @@ -1320,6 +1403,24 @@ export async function runWarpRouteCombine({ ]), ); + 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".`, + ); + } + (route.deployConfig[chain] as any).enrolledRouters = Object.keys(reconciledEnrolledRouters).length > 0 ? reconciledEnrolledRouters From afc9533bc753e710b977d4c9e430978b1db6d926 Mon Sep 17 00:00:00 2001 From: nambrot Date: Sat, 28 Feb 2026 17:46:04 -0500 Subject: [PATCH 07/26] feat(warp-monitor): scope projected deficit to collateralized routes --- typescript/warp-monitor/src/metrics.test.ts | 24 +++- typescript/warp-monitor/src/metrics.ts | 18 +++ typescript/warp-monitor/src/monitor.test.ts | 149 ++++++++++++++++++++ typescript/warp-monitor/src/monitor.ts | 40 +++++- 4 files changed, 221 insertions(+), 10 deletions(-) create mode 100644 typescript/warp-monitor/src/monitor.test.ts diff --git a/typescript/warp-monitor/src/metrics.test.ts b/typescript/warp-monitor/src/metrics.test.ts index 60f056e10c3..801606f5ba8 100644 --- a/typescript/warp-monitor/src/metrics.test.ts +++ b/typescript/warp-monitor/src/metrics.test.ts @@ -15,6 +15,7 @@ import { updateInventoryBalanceMetrics, updateNativeWalletBalanceMetrics, updatePendingDestinationMetrics, + updateProjectedDeficitMetrics, updateTokenBalanceMetrics, updateXERC20LimitsMetrics, } from './metrics.js'; @@ -307,7 +308,7 @@ describe('Warp Monitor Metrics', () => { }); describe('pending destination metrics', () => { - it('should record pending amount/count/age and projected deficit', async () => { + it('should record pending amount/count/age', async () => { resetPendingDestinationMetrics(); updatePendingDestinationMetrics({ warpRouteId: 'MULTI/stableswap', @@ -320,7 +321,6 @@ describe('Warp Monitor Metrics', () => { pendingAmount: 123.45, pendingCount: 3, oldestPendingSeconds: 120, - projectedDeficit: 23.45, }); const metrics = await metricsRegister.metrics(); @@ -333,10 +333,28 @@ describe('Warp Monitor Metrics', () => { expect(metrics).to.include( 'hyperlane_warp_route_pending_destination_oldest_seconds', ); - expect(metrics).to.include('hyperlane_warp_route_projected_deficit'); 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', () => { diff --git a/typescript/warp-monitor/src/metrics.ts b/typescript/warp-monitor/src/metrics.ts index 15b26cef646..fced08a9bb4 100644 --- a/typescript/warp-monitor/src/metrics.ts +++ b/typescript/warp-monitor/src/metrics.ts @@ -35,6 +35,9 @@ type PendingDestinationMetric = BaseRouterMetric & { pendingAmount: number; pendingCount: number; oldestPendingSeconds: number; +}; + +type ProjectedDeficitMetric = BaseRouterMetric & { projectedDeficit: number; }; @@ -196,6 +199,21 @@ export function updatePendingDestinationMetrics( 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); } diff --git a/typescript/warp-monitor/src/monitor.test.ts b/typescript/warp-monitor/src/monitor.test.ts new file mode 100644 index 00000000000..b52023bd3e6 --- /dev/null +++ b/typescript/warp-monitor/src/monitor.test.ts @@ -0,0 +1,149 @@ +import { expect } from 'chai'; + +import { + resetInventoryBalanceMetrics, + resetPendingDestinationMetrics, + metricsRegister, +} from './metrics.js'; +import { WarpMonitor } from './monitor.js'; + +function createMockToken({ + collateralized, + decimals, +}: { + collateralized: boolean; + decimals: number; +}) { + return { + isCollateralized: () => collateralized, + amount: (amount: bigint) => ({ + getDecimalFormattedAmount: () => Number(amount) / 10 ** decimals, + }), + getAdapter: () => ({ + getBalance: async () => 0n, + }), + }; +} + +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 any, + ); + + const collateralizedNodeId = 'COLLAT|anvil2|0xroutera'; + const nonCollateralizedNodeId = 'SYNTH|anvil2|0xrouterb'; + const routerNodes = [ + { + nodeId: collateralizedNodeId, + chainName: 'anvil2', + domainId: 31337, + routerAddress: '0xroutera', + tokenAddress: '0xtokena', + tokenName: 'Collateral Token', + tokenSymbol: 'COLLAT', + tokenDecimals: 6, + token: createMockToken({ + collateralized: true, + decimals: 6, + }), + }, + { + nodeId: nonCollateralizedNodeId, + chainName: 'anvil2', + domainId: 31337, + routerAddress: '0xrouterb', + tokenAddress: '0xtokenb', + tokenName: 'Synthetic Token', + tokenSymbol: 'SYNTH', + tokenDecimals: 6, + token: createMockToken({ + collateralized: false, + decimals: 6, + }), + }, + ] as any; + + const pendingTransfersClient = { + 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, + }, + ]; + }, + }; + + const collateralByNodeId = new Map([ + [collateralizedNodeId, 1_000_000n], + [nonCollateralizedNodeId, 1_000_000n], + ]); + + await (monitor as any).updatePendingAndInventoryMetrics( + { multiProvider: {} }, + routerNodes, + collateralByNodeId, + 'MULTI/deficit-test', + pendingTransfersClient, + 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); + }); +}); diff --git a/typescript/warp-monitor/src/monitor.ts b/typescript/warp-monitor/src/monitor.ts index 36fb3a55652..51b6671fe40 100644 --- a/typescript/warp-monitor/src/monitor.ts +++ b/typescript/warp-monitor/src/monitor.ts @@ -42,6 +42,7 @@ import { updateManagedLockboxBalanceMetrics, updateNativeWalletBalanceMetrics, updatePendingDestinationMetrics, + updateProjectedDeficitMetrics, updateTokenBalanceMetrics, updateXERC20LimitsMetrics, } from './metrics.js'; @@ -291,12 +292,33 @@ export class WarpMonitor { }; 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; - updatePendingDestinationMetrics({ + updateProjectedDeficitMetrics({ warpRouteId, nodeId: node.nodeId, chainName: node.chainName, @@ -304,9 +326,6 @@ export class WarpMonitor { tokenAddress: node.tokenAddress, tokenSymbol: node.tokenSymbol, tokenName: node.tokenName, - pendingAmount: this.formatTokenAmount(node.token, aggregate.amountBaseUnits), - pendingCount: aggregate.count, - oldestPendingSeconds: aggregate.oldestPendingSeconds, projectedDeficit: this.formatTokenAmount( node.token, projectedDeficitBaseUnits, @@ -355,7 +374,10 @@ export class WarpMonitor { tokenSymbol: node.tokenSymbol, tokenName: node.tokenName, inventoryAddress, - inventoryBalance: this.formatTokenAmount(node.token, inventoryBalance), + inventoryBalance: this.formatTokenAmount( + node.token, + inventoryBalance, + ), }); }), ); @@ -375,7 +397,9 @@ export class WarpMonitor { tryFn( async () => { const bridgedSupply = token.isHypToken() - ? await token.getHypAdapter(warpCore.multiProvider).getBridgedSupply() + ? await token + .getHypAdapter(warpCore.multiProvider) + .getBridgedSupply() : undefined; const balanceInfo = await getTokenBridgedBalance( @@ -561,7 +585,9 @@ export class WarpMonitor { chainName: token.chainName, domainId, routerAddress, - tokenAddress: (token.collateralAddressOrDenom ?? token.addressOrDenom).toLowerCase(), + tokenAddress: ( + token.collateralAddressOrDenom ?? token.addressOrDenom + ).toLowerCase(), tokenName: token.name, tokenSymbol: token.symbol, tokenDecimals: token.decimals, From b934636d5984e8e1e3f676b894f661fc5ea7bee8 Mon Sep 17 00:00:00 2001 From: nambrot Date: Sat, 28 Feb 2026 18:15:16 -0500 Subject: [PATCH 08/26] fix(ci): include multicollateral deps in node-service image --- typescript/Dockerfile.node-service | 3 ++- typescript/cli/src/deploy/warp.ts | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) 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/deploy/warp.ts b/typescript/cli/src/deploy/warp.ts index b2d6363050a..ca0894ec5d1 100644 --- a/typescript/cli/src/deploy/warp.ts +++ b/typescript/cli/src/deploy/warp.ts @@ -1273,8 +1273,7 @@ function formatScaleForLogs( scale: WarpCoreConfig['tokens'][number]['scale'], ): string { if (!scale) return '1'; - if (typeof scale === 'number') return scale.toString(); - return `${scale.numerator.toString()}/${scale.denominator.toString()}`; + return scale.toString(); } function getCanonicalWholeTokenRatio( From 337949de25dc978bc2a1675457b5a155b1702e4e Mon Sep 17 00:00:00 2001 From: nambrot Date: Sun, 1 Mar 2026 15:05:40 -0500 Subject: [PATCH 09/26] fix(ci): ignore multicollateral in core eslint --- solidity/eslint.config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/solidity/eslint.config.mjs b/solidity/eslint.config.mjs index 4090a56ebdb..666e252de81 100644 --- a/solidity/eslint.config.mjs +++ b/solidity/eslint.config.mjs @@ -9,6 +9,7 @@ export default [ '**/dist/**/*', '**/lib/**/*', '**/typechain/**/*', + '**/multicollateral/**/*', '**/dependencies/**/*', '**/multicollateral/**/*', '.solcover.js', From 7bb08df2f830d7107a26a483a493880a215e0afc Mon Sep 17 00:00:00 2001 From: nambrot Date: Sun, 1 Mar 2026 15:26:52 -0500 Subject: [PATCH 10/26] style: format warp monitor explorer --- typescript/warp-monitor/src/explorer.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/typescript/warp-monitor/src/explorer.ts b/typescript/warp-monitor/src/explorer.ts index 01616b35464..cb5d9f08fd0 100644 --- a/typescript/warp-monitor/src/explorer.ts +++ b/typescript/warp-monitor/src/explorer.ts @@ -123,7 +123,9 @@ export class ExplorerPendingTransfersClient { let parsedMessage: ReturnType; try { - parsedMessage = parseWarpRouteMessage(normalizeExplorerHex(row.message_body)); + parsedMessage = parseWarpRouteMessage( + normalizeExplorerHex(row.message_body), + ); } catch (error) { this.logger.debug( { @@ -182,7 +184,9 @@ export class ExplorerPendingTransfersClient { return address.replace(/^0x/i, '\\x').toLowerCase(); } - private async queryInflightTransfers(limit: number): Promise { + private async queryInflightTransfers( + limit: number, + ): Promise { if (this.routers.length === 0 || this.domains.length === 0) return []; const variables = { From a1b349e98f2f00f82e29c9c7e5025bec5f36adde Mon Sep 17 00:00:00 2001 From: nambrot Date: Sun, 1 Mar 2026 17:00:08 -0500 Subject: [PATCH 11/26] Apply CodeRabbit fixes for multicollateral + monitor --- solidity/multicollateral/package.json | 5 +- solidity/multicollateral/tsconfig.json | 6 - typescript/cli/src/commands/warp.ts | 3 +- .../sdk/src/token/adapters/EvmTokenAdapter.ts | 14 +- typescript/sdk/src/token/deploy.ts | 2 +- typescript/sdk/src/token/types.ts | 4 +- typescript/warp-monitor/src/explorer.test.ts | 38 ++++++ typescript/warp-monitor/src/explorer.ts | 54 +++++--- typescript/warp-monitor/src/monitor.test.ts | 125 ++++++++++++++++-- typescript/warp-monitor/src/monitor.ts | 47 ++++--- typescript/warp-monitor/src/service.ts | 4 +- 11 files changed, 226 insertions(+), 76 deletions(-) diff --git a/solidity/multicollateral/package.json b/solidity/multicollateral/package.json index 7ef61244df4..cb1466ab113 100644 --- a/solidity/multicollateral/package.json +++ b/solidity/multicollateral/package.json @@ -37,12 +37,13 @@ "test:ci": "pnpm test" }, "dependencies": { - "@hyperlane-xyz/core": "workspace:*", + "@hyperlane-xyz/core": "workspace:*" + }, + "devDependencies": { "@typechain/ethers-v5": "catalog:", "typechain": "catalog:", "typescript": "catalog:" }, - "devDependencies": {}, "peerDependencies": { "@ethersproject/abi": "*", "@ethersproject/providers": "*" diff --git a/solidity/multicollateral/tsconfig.json b/solidity/multicollateral/tsconfig.json index d5ed038c64e..6398e9ce519 100644 --- a/solidity/multicollateral/tsconfig.json +++ b/solidity/multicollateral/tsconfig.json @@ -1,12 +1,6 @@ { "extends": "@hyperlane-xyz/tsconfig/tsconfig.json", "compilerOptions": { - "target": "ES2020", - "module": "Node16", - "moduleResolution": "Node16", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, "outDir": "./dist", "declaration": true }, diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index bf57589bc23..2e2dffe990f 100644 --- a/typescript/cli/src/commands/warp.ts +++ b/typescript/cli/src/commands/warp.ts @@ -189,7 +189,7 @@ export const deploy: CommandModuleWithWarpDeployContext const combine: CommandModuleWithWriteContext<{ routes: string; - 'output-warp-route-id'?: string; + 'output-warp-route-id': string; }> = { command: 'combine', describe: @@ -211,7 +211,6 @@ const combine: CommandModuleWithWriteContext<{ handler: async ({ context, routes, 'output-warp-route-id': outputId }) => { logCommandHeader('Hyperlane Warp Combine'); - assert(outputId, '--output-warp-route-id is required'); const routeIds = routes.split(',').map((r) => r.trim()); await runWarpRouteCombine({ context, diff --git a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts index d3b4e27b82e..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 @@ -321,12 +323,10 @@ export class EvmHypSyntheticAdapter ](destination, recipBytes32, amount.toString()); const [, igpAmount] = igpQuote; - const tokenFeeQuotes: Quote[] = feeQuotes.map( - (quote: { 0: string; 1: any }) => ({ - addressOrDenom: quote[0], - amount: BigInt(quote[1].toString()), - }), - ); + const tokenFeeQuotes: Quote[] = feeQuotes.map((quote: RawTupleQuote) => ({ + addressOrDenom: quote[0], + amount: BigInt(quote[1].toString()), + })); // Because the amount is added on the fees, we need to subtract it from the actual fees const tokenFeeQuote: Quote | undefined = @@ -558,7 +558,7 @@ export class EvmMovableCollateralAdapter 'quoteTransferRemote(uint32,bytes32,uint256)' ](domain, addressToBytes32(recipient), amount); - return quotes.map((quote: { token: string; amount: any }) => ({ + return quotes.map((quote: RawTokenBridgeQuote) => ({ igpQuote: { addressOrDenom: quote.token === ethersConstants.AddressZero ? undefined : quote.token, diff --git a/typescript/sdk/src/token/deploy.ts b/typescript/sdk/src/token/deploy.ts index 1af6c50fb11..c8e23c3384a 100644 --- a/typescript/sdk/src/token/deploy.ts +++ b/typescript/sdk/src/token/deploy.ts @@ -767,7 +767,7 @@ export class HypERC20Deployer extends TokenDeployer { router(contracts: HyperlaneContracts): TokenRouter { for (const key of objKeys(hypERC20factories)) { if (contracts[key]) { - return contracts[key] as unknown as TokenRouter; + return contracts[key] as TokenRouter; } } throw new Error('No matching contract found'); diff --git a/typescript/sdk/src/token/types.ts b/typescript/sdk/src/token/types.ts index 78deb3ab53d..63615f10c12 100644 --- a/typescript/sdk/src/token/types.ts +++ b/typescript/sdk/src/token/types.ts @@ -262,7 +262,9 @@ export const MultiCollateralTokenConfigSchema = type: z.literal(TokenType.multiCollateral), token: z.string().describe('Collateral token address'), /** Map of domain → router addresses to enroll */ - enrolledRouters: z.record(z.string(), z.array(ZHash)).optional(), + enrolledRouters: z + .record(RemoteRouterDomainOrChainNameSchema, z.array(ZHash)) + .optional(), ...BaseMovableTokenConfigSchema.shape, }); export type MultiCollateralTokenConfig = z.infer< diff --git a/typescript/warp-monitor/src/explorer.test.ts b/typescript/warp-monitor/src/explorer.test.ts index 537f687b081..1f1b9d61465 100644 --- a/typescript/warp-monitor/src/explorer.test.ts +++ b/typescript/warp-monitor/src/explorer.test.ts @@ -127,4 +127,42 @@ describe('Explorer Pending Transfers', () => { 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 index cb5d9f08fd0..bfa4b992ffa 100644 --- a/typescript/warp-monitor/src/explorer.ts +++ b/typescript/warp-monitor/src/explorer.ts @@ -229,26 +229,44 @@ export class ExplorerPendingTransfersClient { } `; - const response = await fetch(this.apiUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query, variables }), - }); - - if (!response.ok) { - let details: string; - try { - details = JSON.stringify(await response.json()); - } catch { - details = await response.text(); + 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}`, + ); } - 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)}`, + ); + } - const payload = await response.json(); - return (payload?.data?.message_view ?? []) as ExplorerMessageRow[]; + return payload.data?.message_view ?? []; + } finally { + clearTimeout(timeout); + } } } diff --git a/typescript/warp-monitor/src/monitor.test.ts b/typescript/warp-monitor/src/monitor.test.ts index b52023bd3e6..e1374f77a20 100644 --- a/typescript/warp-monitor/src/monitor.test.ts +++ b/typescript/warp-monitor/src/monitor.test.ts @@ -1,5 +1,13 @@ 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, @@ -13,7 +21,7 @@ function createMockToken({ }: { collateralized: boolean; decimals: number; -}) { +}): Pick { return { isCollateralized: () => collateralized, amount: (amount: bigint) => ({ @@ -37,15 +45,15 @@ describe('WarpMonitor', () => { warpRouteId: 'MULTI/deficit-test', checkFrequency: 10_000, }, - {} as any, + {} as IRegistry, ); const collateralizedNodeId = 'COLLAT|anvil2|0xroutera'; const nonCollateralizedNodeId = 'SYNTH|anvil2|0xrouterb'; - const routerNodes = [ + const routerNodes: RouterNodeMetadata[] = [ { nodeId: collateralizedNodeId, - chainName: 'anvil2', + chainName: 'anvil2' as RouterNodeMetadata['chainName'], domainId: 31337, routerAddress: '0xroutera', tokenAddress: '0xtokena', @@ -55,11 +63,11 @@ describe('WarpMonitor', () => { token: createMockToken({ collateralized: true, decimals: 6, - }), + }) as unknown as Token, }, { nodeId: nonCollateralizedNodeId, - chainName: 'anvil2', + chainName: 'anvil2' as RouterNodeMetadata['chainName'], domainId: 31337, routerAddress: '0xrouterb', tokenAddress: '0xtokenb', @@ -69,11 +77,14 @@ describe('WarpMonitor', () => { token: createMockToken({ collateralized: false, decimals: 6, - }), + }) as unknown as Token, }, - ] as any; + ]; - const pendingTransfersClient = { + const pendingTransfersClient: Pick< + ExplorerPendingTransfersClient, + 'getPendingDestinationTransfers' + > = { async getPendingDestinationTransfers() { return [ { @@ -94,7 +105,7 @@ describe('WarpMonitor', () => { destinationRouter: '0xrouterb', amountBaseUnits: 2_000_000n, }, - ]; + ] satisfies PendingDestinationTransfer[]; }, }; @@ -103,12 +114,24 @@ describe('WarpMonitor', () => { [nonCollateralizedNodeId, 1_000_000n], ]); - await (monitor as any).updatePendingAndInventoryMetrics( - { multiProvider: {} }, + await ( + monitor as unknown as WarpMonitor & { + updatePendingAndInventoryMetrics: ( + warpCore: WarpCore, + routerNodes: RouterNodeMetadata[], + collateralByNodeId: Map, + warpRouteId: string, + pendingTransfersClient?: ExplorerPendingTransfersClient, + explorerQueryLimit?: number, + inventoryAddress?: string, + ) => Promise; + } + ).updatePendingAndInventoryMetrics( + { multiProvider: {} } as WarpCore, routerNodes, collateralByNodeId, 'MULTI/deficit-test', - pendingTransfersClient, + pendingTransfersClient as ExplorerPendingTransfersClient, 200, undefined, ); @@ -146,4 +169,80 @@ describe('WarpMonitor', () => { ), ).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: { + isCollateralized: () => true, + amount: (amount: bigint) => ({ + getDecimalFormattedAmount: () => Number(amount) / 10 ** 6, + }), + getAdapter: () => ({ + getBalance: async () => { + throw new Error('rpc down'); + }, + }), + } as unknown as Token, + }, + ]; + + const pendingTransfersClient: Pick< + ExplorerPendingTransfersClient, + 'getPendingDestinationTransfers' + > = { + async getPendingDestinationTransfers() { + return [] as PendingDestinationTransfer[]; + }, + }; + + await ( + monitor as unknown as WarpMonitor & { + updatePendingAndInventoryMetrics: ( + warpCore: WarpCore, + routerNodes: RouterNodeMetadata[], + collateralByNodeId: Map, + warpRouteId: string, + pendingTransfersClient?: ExplorerPendingTransfersClient, + explorerQueryLimit?: number, + inventoryAddress?: string, + ) => Promise; + } + ).updatePendingAndInventoryMetrics( + { 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); + }); }); diff --git a/typescript/warp-monitor/src/monitor.ts b/typescript/warp-monitor/src/monitor.ts index 51b6671fe40..d276c71e3e2 100644 --- a/typescript/warp-monitor/src/monitor.ts +++ b/typescript/warp-monitor/src/monitor.ts @@ -354,31 +354,30 @@ export class WarpMonitor { await Promise.all( routerNodes.map(async (node) => { - let inventoryBalance = 0n; + try { + const adapter = node.token.getAdapter(warpCore.multiProvider); + const inventoryBalance = await adapter.getBalance(inventoryAddress); - await tryFn( - async () => { - const adapter = node.token.getAdapter(warpCore.multiProvider); - inventoryBalance = await adapter.getBalance(inventoryAddress); - }, - `Reading inventory balance for ${node.nodeId}`, - logger, - ); - - 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, - ), - }); + 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) { + logger.error( + { nodeId: node.nodeId, err: error as Error }, + `Reading inventory balance for ${node.nodeId} failed`, + ); + } }), ); } diff --git a/typescript/warp-monitor/src/service.ts b/typescript/warp-monitor/src/service.ts index a8b13366d38..27e6b61eefe 100644 --- a/typescript/warp-monitor/src/service.ts +++ b/typescript/warp-monitor/src/service.ts @@ -57,8 +57,8 @@ async function main(): Promise { let explorerQueryLimit = 200; if (process.env.EXPLORER_QUERY_LIMIT) { - const parsed = parseInt(process.env.EXPLORER_QUERY_LIMIT, 10); - if (isNaN(parsed) || parsed <= 0) { + 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); } From 66b2a6e22af607a4338e87f91294e3b683d75e31 Mon Sep 17 00:00:00 2001 From: nambrot Date: Mon, 2 Mar 2026 12:57:06 -0500 Subject: [PATCH 12/26] Address CodeRabbit transfer and quote robustness feedback --- typescript/cli/src/send/transfer.ts | 4 ++++ .../ethereum/warp/warp-multi-collateral.e2e-test.ts | 7 ++++--- .../sdk/src/token/adapters/EvmMultiCollateralAdapter.ts | 9 +++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/typescript/cli/src/send/transfer.ts b/typescript/cli/src/send/transfer.ts index 65544b6238c..d2a6fd81e91 100644 --- a/typescript/cli/src/send/transfer.ts +++ b/typescript/cli/src/send/transfer.ts @@ -207,6 +207,10 @@ 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 messages = HyperlaneCore.getDispatchedMessages(transferTxReceipt); const message: DispatchedMessage | undefined = messages[0]; 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 index b3d94a9033e..901e2c49442 100644 --- 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 @@ -166,6 +166,7 @@ describe('hyperlane warp multiCollateral CLI e2e tests', async function () { // 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, @@ -177,9 +178,9 @@ describe('hyperlane warp multiCollateral CLI e2e tests', async function () { }); // Verify: 1 USDC(6dec) → canonical 1e18 → 1 USDT(18dec) = 1e18 - const recipientBalance = await usdtChain3.balanceOf(ownerAddress); - // The balance should include the received 1 USDT (1e18) - expect(recipientBalance.gte(parseUnits('1', 18))).to.be.true; + 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 () { diff --git a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts index 49fd6cc5e32..f63812f63db 100644 --- a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts +++ b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts @@ -10,6 +10,7 @@ import { Domain, Numberish, addressToBytes32, + assert, } from '@hyperlane-xyz/utils'; import { BaseEvmAdapter } from '../../app/MultiProtocolApp.js'; @@ -212,6 +213,10 @@ export class EvmHypMultiCollateralAdapter params.amount.toString(), targetRouterBytes32, ); + assert( + quotes.length >= 1, + 'quoteTransferRemoteTo returned no native quote', + ); const nativeGas = quotes[0].amount; return this.contract.populateTransaction.transferRemoteTo( @@ -241,6 +246,10 @@ export class EvmHypMultiCollateralAdapter params.amount.toString(), targetRouterBytes32, ); + assert( + quotes.length >= 3, + 'quoteTransferRemoteTo returned incomplete quote set', + ); const amount = BigInt(params.amount.toString()); const tokenQuoteAmount = BigInt(quotes[1].amount.toString()); From f918524932fe57bcc7a35d696b28e54d20f31ca6 Mon Sep 17 00:00:00 2001 From: nambrot Date: Mon, 2 Mar 2026 15:01:18 -0500 Subject: [PATCH 13/26] fix(lockfile): restore multicollateral devDependencies section --- pnpm-lock.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1e8acd5f97..347a79000b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -507,6 +507,7 @@ importers: '@hyperlane-xyz/core': specifier: workspace:* version: link:.. + devDependencies: '@typechain/ethers-v5': specifier: 11.1.2 version: 11.1.2(@ethersproject/abi@5.8.0)(@ethersproject/providers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=fbb49bf3d1f71d8430767373c9ae33cd36e1aedd8ae9584d00dc90775f804950)(typescript@5.8.3))(typescript@5.8.3) From 5b2a9cdb9061eab6ea7e978e40293926e1e7c70f Mon Sep 17 00:00:00 2001 From: nambrot Date: Mon, 2 Mar 2026 16:02:46 -0500 Subject: [PATCH 14/26] Fix multicollateral routing + fee quoting edge cases --- docs/multi-collateral-design.md | 119 -------------- .../src/token/EvmWarpModule.hardhat-test.ts | 67 ++++++++ typescript/sdk/src/token/EvmWarpModule.ts | 30 +++- typescript/sdk/src/token/Token.ts | 6 +- .../EvmMultiCollateralAdapter.test.ts | 72 +++++++++ .../adapters/EvmMultiCollateralAdapter.ts | 44 ++--- typescript/sdk/src/warp/WarpCore.test.ts | 151 ++++++++++++++++++ typescript/sdk/src/warp/WarpCore.ts | 59 +++++-- typescript/warp-monitor/src/monitor.test.ts | 141 ++++++++++++---- typescript/warp-monitor/src/monitor.ts | 11 +- 10 files changed, 506 insertions(+), 194 deletions(-) delete mode 100644 docs/multi-collateral-design.md diff --git a/docs/multi-collateral-design.md b/docs/multi-collateral-design.md deleted file mode 100644 index 4c59373b292..00000000000 --- a/docs/multi-collateral-design.md +++ /dev/null @@ -1,119 +0,0 @@ -# MultiCollateral Design Plan - -## Context - -Replace sUSD Hub + ICA design (2 messages, non-atomic) with direct peer-to-peer collateral routing (1 message, atomic). Each deployed instance holds collateral for one ERC20. Enrolled routers are other MultiCollateral instances (same or different token). - -**Decisions:** Fees on `localTransferTo` (ITokenFee gets params, decides by domain). Batch-only enrollment. Config like normal warp routes (movable collateral, owners, etc). - ---- - -## Phase 1: Contract (DONE) - -**File:** `solidity/contracts/token/extensions/MultiCollateral.sol` - -**Extends:** `HypERC20Collateral` — inherits rebalancing, LP staking, fees, decimal scaling, `_transferFromSender`/`_transferTo`. - -### Storage - -```solidity -mapping(uint32 domain => mapping(bytes32 router => bool)) public enrolledRouters; -``` - -Single mapping for both cross-chain and local routers. Local routers use `localDomain` as key. - -### Functions - -**Router management (onlyOwner, batch-only):** - -- `enrollRouters(uint32[] domains, bytes32[] routers)` — batch enroll -- `unenrollRouters(uint32[] domains, bytes32[] routers)` — batch unenroll - -**`handle()` override** (overrides `Router.handle`): - -```solidity -function handle( - uint32 _origin, - bytes32 _sender, - bytes calldata _message -) external payable override onlyMailbox { - require( - _isRemoteRouter(_origin, _sender) || enrolledRouters[_origin][_sender], - 'MC: unauthorized router' - ); - _handle(_origin, _sender, _message); -} -``` - -**`transferRemoteTo()`** — cross-chain to specific router: - -- Checks `_isRemoteRouter || enrolledRouters` -- Reuses fee pipeline from TokenRouter -- Dispatches directly to target (bypasses `_Router_dispatch` which hardcodes enrolled router) - -**`localTransferTo()`** — same-chain swap with fees: - -- Checks `enrolledRouters[localDomain]` -- Charges fee via `_feeRecipientAndAmount(localDomain, ...)` -- Calls `MultiCollateral(_targetRouter).receiveLocalSwap(canonical, recipient)` - -**`receiveLocalSwap()`** — called by local enrolled router - -**`quoteTransferRemoteTo()`** — returns 3 quotes: native gas, token+fee, external fee - -### Events - -- `RouterEnrolled(uint32 indexed domain, bytes32 indexed router)` -- `RouterUnenrolled(uint32 indexed domain, bytes32 indexed router)` -- Reuse `SentTransferRemote` from TokenRouter - ---- - -## Phase 2: Forge Tests (DONE) - -**File:** `solidity/test/token/extensions/MultiCollateral.t.sol` - -22 tests covering: cross-chain same-stablecoin, cross-chain cross-stablecoin, same-chain swap, fees, decimal scaling (6↔18), unauthorized reverts, owner-only enrollment, bidirectional transfers, batch enroll/unenroll, events, quoting. - ---- - -## Phase 3: SDK Registration (DONE) - -| File | Change | -| ------------------------------------------------ | --------------------------------------------------------------- | -| `typescript/sdk/src/token/config.ts` | `TokenType.multiCollateral`, movable map entry | -| `typescript/sdk/src/token/types.ts` | `MultiCollateralTokenConfigSchema` with `enrolledRouters` field | -| `typescript/sdk/src/token/contracts.ts` | `MultiCollateral__factory` import/mapping | -| `typescript/sdk/src/token/deploy.ts` | Constructor/init args + batch `enrollRouters()` post-deploy | -| `typescript/sdk/src/token/TokenStandard.ts` | `EvmHypMultiCollateral` enum + mappings | -| `typescript/sdk/src/token/Token.ts` | Adapter mapping → `EvmMovableCollateralAdapter` | -| `typescript/sdk/src/token/tokenMetadataUtils.ts` | `isMultiCollateralTokenConfig` | - ---- - -## Phase 4: CLI E2E Tests (DONE) - -**File:** `typescript/cli/src/tests/ethereum/warp/warp-multi-peer.e2e-test.ts` - -3 tests: - -1. Same-stablecoin round trip via CLI `transferRemote` -2. Cross-stablecoin via `transferRemoteTo` (USDC→USDT, decimal scaling) -3. Same-chain local swap via `localTransferTo` - -Also added `multiCollateral` to `ignoreTokenTypes` in `generateWarpConfigs`. - ---- - -## Verification - -```bash -# Solidity (22/22 pass) -forge test --match-contract MultiCollateralTest -vvv - -# SDK builds (pre-existing type errors only) -pnpm -C typescript/sdk build - -# CLI e2e (slow) -pnpm -C typescript/cli test:ethereum:e2e -``` diff --git a/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts b/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts index b3153939088..e737fd5df72 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, @@ -895,6 +896,72 @@ describe('EvmWarpModule', async () => { ); }); + it('normalizes chain-name enrolledRouters keys for multicollateral enroll/unenroll txs', async () => { + const destinationDomain = multiProvider.getDomainId(TestChainName.test2); + const keepRouter = addressToBytes32(randomAddress()); + const addRouter = addressToBytes32(randomAddress()); + const removeRouter = addressToBytes32(randomAddress()); + + 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]: [keepRouter, 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 885e4be3955..746d8bbc87c 100644 --- a/typescript/sdk/src/token/EvmWarpModule.ts +++ b/typescript/sdk/src/token/EvmWarpModule.ts @@ -440,17 +440,24 @@ export class EvmWarpModule extends HyperlaneModule< return []; } - const actualEnrolled = actualConfig.enrolledRouters ?? {}; - const expectedEnrolled = expectedConfig.enrolledRouters; + 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 actualRouters = new Set(actualEnrolled[domain] ?? []); + const domainId = Number(domain); + const actualRouters = new Set(actualEnrolled[domainId] ?? []); for (const router of expectedRouters) { if (!actualRouters.has(router)) { - domainsToEnroll.push(Number(domain)); + domainsToEnroll.push(domainId); routersToEnroll.push(router); } } @@ -487,17 +494,24 @@ export class EvmWarpModule extends HyperlaneModule< return []; } - const actualEnrolled = actualConfig.enrolledRouters ?? {}; - const expectedEnrolled = expectedConfig.enrolledRouters ?? {}; + 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 expectedRouters = new Set(expectedEnrolled[domain] ?? []); + const domainId = Number(domain); + const expectedRouters = new Set(expectedEnrolled[domainId] ?? []); for (const router of actualRouters) { if (!expectedRouters.has(router)) { - domainsToUnenroll.push(Number(domain)); + domainsToUnenroll.push(domainId); routersToUnenroll.push(router); } } diff --git a/typescript/sdk/src/token/Token.ts b/typescript/sdk/src/token/Token.ts index 2b27bced326..4b63ae76deb 100644 --- a/typescript/sdk/src/token/Token.ts +++ b/typescript/sdk/src/token/Token.ts @@ -266,9 +266,13 @@ export class Token implements IToken { token: addressOrDenom, }); } else if (standard === TokenStandard.EvmHypMultiCollateral) { + assert( + collateralAddressOrDenom, + 'collateralAddressOrDenom required for EvmHypMultiCollateral', + ); return new EvmHypMultiCollateralAdapter(chainName, multiProvider, { token: addressOrDenom, - collateralToken: collateralAddressOrDenom ?? addressOrDenom, + collateralToken: collateralAddressOrDenom, }); } else if (standard === TokenStandard.EvmHypRebaseCollateral) { return new EvmHypRebaseCollateralAdapter(chainName, multiProvider, { diff --git a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts index 4dcd9291a48..eae142dcc1a 100644 --- a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts +++ b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts @@ -68,4 +68,76 @@ describe('EvmHypMultiCollateralAdapter', () => { 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).contract = { 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).contract = { + 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).contract = { + 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'); + }); }); diff --git a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts index f63812f63db..05e4989dbf6 100644 --- a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts +++ b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts @@ -11,6 +11,7 @@ import { Numberish, addressToBytes32, assert, + isZeroishAddress, } from '@hyperlane-xyz/utils'; import { BaseEvmAdapter } from '../../app/MultiProtocolApp.js'; @@ -197,27 +198,35 @@ export class EvmHypMultiCollateralAdapter /** * Populate cross-chain transfer to a specific target router. */ - async populateTransferRemoteToTx(params: { + private async quoteTransferRemoteToRaw(params: { destination: Domain; recipient: Address; amount: Numberish; targetRouter: Address; - }): Promise { + }) { const recipientBytes32 = addressToBytes32(params.recipient); const targetRouterBytes32 = addressToBytes32(params.targetRouter); - // Quote gas - const quotes = await this.contract.quoteTransferRemoteTo( + return this.contract.quoteTransferRemoteTo( params.destination, recipientBytes32, params.amount.toString(), targetRouterBytes32, ); - assert( - quotes.length >= 1, - 'quoteTransferRemoteTo returned no native quote', - ); - const nativeGas = quotes[0].amount; + } + + async populateTransferRemoteToTx(params: { + destination: Domain; + recipient: Address; + amount: Numberish; + targetRouter: Address; + }): Promise { + const recipientBytes32 = addressToBytes32(params.recipient); + const targetRouterBytes32 = addressToBytes32(params.targetRouter); + const quote = await this.quoteTransferRemoteToGas(params); + const nativeGas = !quote.igpQuote.addressOrDenom + ? quote.igpQuote.amount.toString() + : '0'; return this.contract.populateTransaction.transferRemoteTo( params.destination, @@ -237,15 +246,7 @@ export class EvmHypMultiCollateralAdapter amount: Numberish; targetRouter: Address; }): Promise { - const recipientBytes32 = addressToBytes32(params.recipient); - const targetRouterBytes32 = addressToBytes32(params.targetRouter); - - const quotes = await this.contract.quoteTransferRemoteTo( - params.destination, - recipientBytes32, - params.amount.toString(), - targetRouterBytes32, - ); + const quotes = await this.quoteTransferRemoteToRaw(params); assert( quotes.length >= 3, 'quoteTransferRemoteTo returned incomplete quote set', @@ -260,7 +261,12 @@ export class EvmHypMultiCollateralAdapter : externalFeeAmount; return { - igpQuote: { amount: BigInt(quotes[0].amount.toString()) }, + 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/warp/WarpCore.test.ts b/typescript/sdk/src/warp/WarpCore.test.ts index de030ef9b5a..e0f1693daca 100644 --- a/typescript/sdk/src/warp/WarpCore.test.ts +++ b/typescript/sdk/src/warp/WarpCore.test.ts @@ -572,6 +572,68 @@ describe('WarpCore', () => { } }); + 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') @@ -616,6 +678,95 @@ describe('WarpCore', () => { } }); + 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 f98e62e9fb8..389f3e19fbb 100644 --- a/typescript/sdk/src/warp/WarpCore.ts +++ b/typescript/sdk/src/warp/WarpCore.ts @@ -167,8 +167,13 @@ export class WarpCore { let quote: InterchainGasQuote; const destinationDomainId = this.multiProvider.getDomainId(destination); if (this.isMultiCollateralTransfer(originToken, destinationToken)) { + const resolvedDestinationToken = this.resolveDestinationToken({ + originToken, + destination, + destinationToken, + }); assert( - destinationToken?.addressOrDenom, + resolvedDestinationToken.addressOrDenom, 'Destination token missing addressOrDenom', ); const multiCollateralAdapter = originToken.getHypAdapter( @@ -179,7 +184,7 @@ export class WarpCore { destination: destinationDomainId, recipient, amount, - targetRouter: destinationToken.addressOrDenom, + targetRouter: resolvedDestinationToken.addressOrDenom, }); } else { const hypAdapter = originToken.getHypAdapter( @@ -601,13 +606,18 @@ export class WarpCore { 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( - destinationToken.addressOrDenom, + resolvedDestinationToken.addressOrDenom, 'Destination token missing addressOrDenom', ); @@ -622,17 +632,31 @@ export class WarpCore { destination: this.multiProvider.getDomainId(destination), recipient, amount, - targetRouter: destinationToken.addressOrDenom!, + targetRouter: resolvedDestinationToken.addressOrDenom, }); const tokenFeeAmount = transferQuote.tokenFeeQuote?.amount ?? 0n; const totalDebit = amount + tokenFeeAmount; - // Check approval - const isApproveRequired = await adapter.isApproveRequired( - sender, - originToken.addressOrDenom!, - totalDebit, - ); + 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({ @@ -654,7 +678,7 @@ export class WarpCore { destination: destinationDomainId, recipient, amount, - targetRouter: destinationToken.addressOrDenom!, + targetRouter: resolvedDestinationToken.addressOrDenom, }); transactions.push({ category: WarpTxCategory.Transfer, @@ -745,6 +769,11 @@ export class WarpCore { }): Promise { const { token: originToken } = originTokenAmount; const destinationName = this.multiProvider.getChainName(destination); + const resolvedDestinationToken = this.resolveDestinationToken({ + originToken, + destination, + destinationToken, + }); const originMetadata = this.multiProvider.getChainMetadata( originToken.chainName, @@ -757,7 +786,7 @@ export class WarpCore { 'Origin token missing collateralAddressOrDenom', ); assert( - destinationToken.addressOrDenom, + resolvedDestinationToken.addressOrDenom, 'Destination token missing addressOrDenom', ); @@ -772,7 +801,7 @@ export class WarpCore { destination: destinationDomainId, recipient, amount: originTokenAmount.amount, - targetRouter: destinationToken.addressOrDenom!, + targetRouter: resolvedDestinationToken.addressOrDenom, }); let tokenFeeQuote: TokenAmount | undefined; @@ -1151,10 +1180,10 @@ export class WarpCore { await destinationAdapter.getMinimumTransferAmount(recipient); // Convert the minDestinationTransferAmount to an origin amount - const minOriginTransferAmount = resolvedDestinationToken.amount( + const minOriginTransferAmount = originToken.amount( convertDecimalsToIntegerString( - originToken.decimals, resolvedDestinationToken.decimals, + originToken.decimals, minDestinationTransferAmount.toString(), ), ); diff --git a/typescript/warp-monitor/src/monitor.test.ts b/typescript/warp-monitor/src/monitor.test.ts index e1374f77a20..1e65d1e9ed2 100644 --- a/typescript/warp-monitor/src/monitor.test.ts +++ b/typescript/warp-monitor/src/monitor.test.ts @@ -21,7 +21,7 @@ function createMockToken({ }: { collateralized: boolean; decimals: number; -}): Pick { +}): Token { return { isCollateralized: () => collateralized, amount: (amount: bigint) => ({ @@ -30,7 +30,40 @@ function createMockToken({ getAdapter: () => ({ getBalance: async () => 0n, }), - }; + } as unknown as Token; +} + +async function invokeUpdatePendingAndInventoryMetrics( + monitor: WarpMonitor, + warpCore: WarpCore, + routerNodes: RouterNodeMetadata[], + collateralByNodeId: Map, + warpRouteId: string, + pendingTransfersClient?: ExplorerPendingTransfersClient, + explorerQueryLimit?: number, + inventoryAddress?: string, +) { + await ( + monitor as unknown as { + updatePendingAndInventoryMetrics: ( + warpCore: WarpCore, + routerNodes: RouterNodeMetadata[], + collateralByNodeId: Map, + warpRouteId: string, + pendingTransfersClient?: ExplorerPendingTransfersClient, + explorerQueryLimit?: number, + inventoryAddress?: string, + ) => Promise; + } + ).updatePendingAndInventoryMetrics( + warpCore, + routerNodes, + collateralByNodeId, + warpRouteId, + pendingTransfersClient, + explorerQueryLimit, + inventoryAddress, + ); } describe('WarpMonitor', () => { @@ -114,19 +147,8 @@ describe('WarpMonitor', () => { [nonCollateralizedNodeId, 1_000_000n], ]); - await ( - monitor as unknown as WarpMonitor & { - updatePendingAndInventoryMetrics: ( - warpCore: WarpCore, - routerNodes: RouterNodeMetadata[], - collateralByNodeId: Map, - warpRouteId: string, - pendingTransfersClient?: ExplorerPendingTransfersClient, - explorerQueryLimit?: number, - inventoryAddress?: string, - ) => Promise; - } - ).updatePendingAndInventoryMetrics( + await invokeUpdatePendingAndInventoryMetrics( + monitor, { multiProvider: {} } as WarpCore, routerNodes, collateralByNodeId, @@ -213,19 +235,8 @@ describe('WarpMonitor', () => { }, }; - await ( - monitor as unknown as WarpMonitor & { - updatePendingAndInventoryMetrics: ( - warpCore: WarpCore, - routerNodes: RouterNodeMetadata[], - collateralByNodeId: Map, - warpRouteId: string, - pendingTransfersClient?: ExplorerPendingTransfersClient, - explorerQueryLimit?: number, - inventoryAddress?: string, - ) => Promise; - } - ).updatePendingAndInventoryMetrics( + await invokeUpdatePendingAndInventoryMetrics( + monitor, { multiProvider: {} } as WarpCore, routerNodes, new Map([[nodeId, 1_000_000n]]), @@ -245,4 +256,78 @@ describe('WarpMonitor', () => { 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: { + isCollateralized: () => true, + amount: (amount: bigint) => ({ + getDecimalFormattedAmount: () => Number(amount) / 10 ** 6, + }), + getAdapter: () => ({ + getBalance: async () => 1_000_000n, + }), + } as unknown as Token, + }, + ]; + + 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 d276c71e3e2..a711fe3c51f 100644 --- a/typescript/warp-monitor/src/monitor.ts +++ b/typescript/warp-monitor/src/monitor.ts @@ -273,10 +273,11 @@ export class WarpMonitor { pendingByNodeId.set(transfer.destinationNodeId, aggregate); } - } catch (error) { + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); logger.error( { - error: (error as Error).message, + error: message, }, 'Failed to query explorer pending transfers', ); @@ -372,9 +373,11 @@ export class WarpMonitor { inventoryBalance, ), }); - } catch (error) { + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : String(error); logger.error( - { nodeId: node.nodeId, err: error as Error }, + { nodeId: node.nodeId, error: message }, `Reading inventory balance for ${node.nodeId} failed`, ); } From f3ee80ebd610d833d507261cbf14eb0adbe3ebff Mon Sep 17 00:00:00 2001 From: nambrot Date: Mon, 2 Mar 2026 17:36:20 -0500 Subject: [PATCH 15/26] fix: address multicollateral PR-B review issues --- typescript/cli/src/commands/warp.ts | 9 ++- typescript/cli/src/deploy/warp.test.ts | 36 ++++++++++ typescript/cli/src/deploy/warp.ts | 50 +++++++------- .../src/token/EvmWarpModule.hardhat-test.ts | 11 ++-- typescript/sdk/src/token/EvmWarpModule.ts | 29 ++++++-- .../token/EvmWarpRouteReader.hardhat-test.ts | 66 +++++++++++++++++++ .../sdk/src/token/EvmWarpRouteReader.ts | 4 +- typescript/sdk/src/warp/WarpCore.test.ts | 42 ++++++++++++ typescript/sdk/src/warp/WarpCore.ts | 46 ++----------- typescript/warp-monitor/src/monitor.test.ts | 36 +++++----- 10 files changed, 237 insertions(+), 92 deletions(-) diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index 2e2dffe990f..19e9f9aa8b0 100644 --- a/typescript/cli/src/commands/warp.ts +++ b/typescript/cli/src/commands/warp.ts @@ -211,7 +211,14 @@ const combine: CommandModuleWithWriteContext<{ handler: async ({ context, routes, 'output-warp-route-id': outputId }) => { logCommandHeader('Hyperlane Warp Combine'); - const routeIds = routes.split(',').map((r) => r.trim()); + 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', + ); await runWarpRouteCombine({ context, routeIds, diff --git a/typescript/cli/src/deploy/warp.test.ts b/typescript/cli/src/deploy/warp.test.ts index 198dd08c350..b87f10a9f3b 100644 --- a/typescript/cli/src/deploy/warp.test.ts +++ b/typescript/cli/src/deploy/warp.test.ts @@ -105,6 +105,7 @@ describe('runWarpRouteCombine', () => { deployConfig: { anvil2: { type: TokenType.multiCollateral, + owner: ROUTER_A, token: ROUTER_A, enrolledRouters: { [DOMAIN_BY_CHAIN.anvil3.toString()]: [addressToBytes32(ROUTER_C)], @@ -126,6 +127,7 @@ describe('runWarpRouteCombine', () => { deployConfig: { anvil3: { type: TokenType.multiCollateral, + owner: ROUTER_B, token: ROUTER_B, }, }, @@ -159,6 +161,36 @@ describe('runWarpRouteCombine', () => { }); }); + 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: { @@ -174,6 +206,7 @@ describe('runWarpRouteCombine', () => { deployConfig: { anvil2: { type: TokenType.multiCollateral, + owner: ROUTER_A, token: ROUTER_A, }, }, @@ -195,6 +228,7 @@ describe('runWarpRouteCombine', () => { deployConfig: { anvil3: { type: TokenType.collateral, + owner: ROUTER_B, token: ROUTER_B, }, }, @@ -237,6 +271,7 @@ describe('runWarpRouteCombine', () => { deployConfig: { anvil2: { type: TokenType.multiCollateral, + owner: ROUTER_A, token: ROUTER_A, scale: 1_000_000_000_000, }, @@ -257,6 +292,7 @@ describe('runWarpRouteCombine', () => { deployConfig: { anvil2: { type: TokenType.multiCollateral, + owner: ROUTER_B, token: ROUTER_B, scale: 2, }, diff --git a/typescript/cli/src/deploy/warp.ts b/typescript/cli/src/deploy/warp.ts index ca0894ec5d1..a8337d6c188 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,6 +37,7 @@ import { type TypedAnnotatedTransaction, type WarpCoreConfig, WarpCoreConfigSchema, + type WarpRouteDeployConfig, type WarpRouteDeployConfigMailboxRequired, WarpRouteDeployConfigSchema, TokenStandard, @@ -269,11 +271,10 @@ export async function runWarpRouteDeploy({ warpRouteIdOptions = addWarpRouteOptions; } - await writeDeploymentArtifacts( - warpCoreConfig, - context, - warpRouteIdOptions, + await writeDeploymentArtifacts(warpCoreConfig, context, warpRouteIdOptions); + await context.registry.addWarpRouteConfig( warpDeployConfig, + warpRouteIdOptions, ); await completeDeploy( @@ -333,19 +334,9 @@ async function writeDeploymentArtifacts( warpCoreConfig: WarpCoreConfig, context: WriteCommandContext, addWarpRouteOptions?: AddWarpRouteConfigOptions, - warpDeployConfig?: WarpRouteDeployConfigMailboxRequired, ) { log('Writing deployment artifacts...'); await context.registry.addWarpRoute(warpCoreConfig, addWarpRouteOptions); - - // Save deploy config so `warp combine` can read it later - if (warpDeployConfig && addWarpRouteOptions) { - await context.registry.addWarpRouteConfig( - warpDeployConfig, - addWarpRouteOptions, - ); - } - log(indentYamlOrJson(yamlStringify(warpCoreConfig, null, 2), 4)); } @@ -718,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) { @@ -1261,7 +1255,7 @@ export async function getSubmitterByStrategy({ type CombineRouteConfig = { id: string; coreConfig: WarpCoreConfig; - deployConfig: WarpRouteDeployConfigMailboxRequired; + deployConfig: WarpRouteDeployConfig; }; type CanonicalWholeTokenRatio = { @@ -1354,6 +1348,14 @@ export async function runWarpRouteCombine({ 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[] = []; @@ -1361,12 +1363,12 @@ export async function runWarpRouteCombine({ for (const id of routeIds) { const coreConfig = await context.registry.getWarpRoute(id); assert(coreConfig, `Warp route "${id}" not found in registry`); - const deployConfig = await context.registry.getWarpDeployConfig(id); - assert(deployConfig, `Deploy config for "${id}" not found in registry`); + const deployConfigRaw = await context.registry.getWarpDeployConfig(id); + const deployConfig = WarpRouteDeployConfigSchema.parse(deployConfigRaw); routes.push({ id, coreConfig, - deployConfig: deployConfig as WarpRouteDeployConfigMailboxRequired, + deployConfig, }); } @@ -1374,7 +1376,9 @@ export async function runWarpRouteCombine({ // 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)) { + for (const [chain, chainConfig] of Object.entries( + route.deployConfig, + ) as Array<[string, HypTokenRouterConfig]>) { if (!isMultiCollateralTokenConfig(chainConfig)) continue; const enrolledRouters: Record> = {}; @@ -1420,7 +1424,7 @@ export async function runWarpRouteCombine({ ); } - (route.deployConfig[chain] as any).enrolledRouters = + chainConfig.enrolledRouters = Object.keys(reconciledEnrolledRouters).length > 0 ? reconciledEnrolledRouters : undefined; diff --git a/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts b/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts index e737fd5df72..4578743096c 100644 --- a/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts +++ b/typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts @@ -898,9 +898,12 @@ describe('EvmWarpModule', async () => { it('normalizes chain-name enrolledRouters keys for multicollateral enroll/unenroll txs', async () => { const destinationDomain = multiProvider.getDomainId(TestChainName.test2); - const keepRouter = addressToBytes32(randomAddress()); - const addRouter = addressToBytes32(randomAddress()); - const removeRouter = addressToBytes32(randomAddress()); + 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, @@ -929,7 +932,7 @@ describe('EvmWarpModule', async () => { type: TokenType.multiCollateral, token: token.address, enrolledRouters: { - [TestChainName.test2]: [keepRouter, addRouter], + [TestChainName.test2]: [keepRouterAddress.toUpperCase(), addRouter], }, } as HypTokenRouterConfig; diff --git a/typescript/sdk/src/token/EvmWarpModule.ts b/typescript/sdk/src/token/EvmWarpModule.ts index 746d8bbc87c..1a77a0c20c9 100644 --- a/typescript/sdk/src/token/EvmWarpModule.ts +++ b/typescript/sdk/src/token/EvmWarpModule.ts @@ -454,11 +454,16 @@ export class EvmWarpModule extends HyperlaneModule< for (const [domain, expectedRouters] of Object.entries(expectedEnrolled)) { const domainId = Number(domain); - const actualRouters = new Set(actualEnrolled[domainId] ?? []); + const actualRouters = new Set( + (actualEnrolled[domainId] ?? []).map((router) => + this.toCanonicalRouterId(router), + ), + ); for (const router of expectedRouters) { - if (!actualRouters.has(router)) { + const canonicalRouter = this.toCanonicalRouterId(router); + if (!actualRouters.has(canonicalRouter)) { domainsToEnroll.push(domainId); - routersToEnroll.push(router); + routersToEnroll.push(canonicalRouter); } } } @@ -508,11 +513,16 @@ export class EvmWarpModule extends HyperlaneModule< for (const [domain, actualRouters] of Object.entries(actualEnrolled)) { const domainId = Number(domain); - const expectedRouters = new Set(expectedEnrolled[domainId] ?? []); + const expectedRouters = new Set( + (expectedEnrolled[domainId] ?? []).map((router) => + this.toCanonicalRouterId(router), + ), + ); for (const router of actualRouters) { - if (!expectedRouters.has(router)) { + const canonicalRouter = this.toCanonicalRouterId(router); + if (!expectedRouters.has(canonicalRouter)) { domainsToUnenroll.push(domainId); - routersToUnenroll.push(router); + routersToUnenroll.push(canonicalRouter); } } } @@ -534,6 +544,13 @@ export class EvmWarpModule extends HyperlaneModule< ]; } + private toCanonicalRouterId(router: string): string { + if (router.length === 42) { + return addressToBytes32(router.toLowerCase()); + } + return router.toLowerCase(); + } + async getAllowedBridgesApprovalTxs( actualConfig: DerivedTokenRouterConfig, expectedConfig: HypTokenRouterConfig, diff --git a/typescript/sdk/src/token/EvmWarpRouteReader.hardhat-test.ts b/typescript/sdk/src/token/EvmWarpRouteReader.hardhat-test.ts index 9c20b7aa76b..86ceb1cd157 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,71 @@ 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; + 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], + }); + + 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 ee67e6139b5..18281871366 100644 --- a/typescript/sdk/src/token/EvmWarpRouteReader.ts +++ b/typescript/sdk/src/token/EvmWarpRouteReader.ts @@ -1142,11 +1142,12 @@ export class EvmWarpRouteReader extends EvmRouterReader { this.provider, ); - const [collateralTokenAddress, remoteDomains, localDomain] = + const [collateralTokenAddress, remoteDomains, localDomain, scale] = await Promise.all([ mc.wrappedToken(), tokenRouter.domains(), mc.localDomain(), + this.fetchScale(hypTokenAddress), ]); const erc20TokenMetadata = await this.fetchERC20Metadata( @@ -1170,6 +1171,7 @@ export class EvmWarpRouteReader extends EvmRouterReader { ...erc20TokenMetadata, type: TokenType.multiCollateral, token: collateralTokenAddress, + scale, enrolledRouters: Object.keys(enrolledRouters).length > 0 ? enrolledRouters : undefined, }; diff --git a/typescript/sdk/src/warp/WarpCore.test.ts b/typescript/sdk/src/warp/WarpCore.test.ts index e0f1693daca..93b81dbdc72 100644 --- a/typescript/sdk/src/warp/WarpCore.test.ts +++ b/typescript/sdk/src/warp/WarpCore.test.ts @@ -678,6 +678,48 @@ describe('WarpCore', () => { } }); + 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') diff --git a/typescript/sdk/src/warp/WarpCore.ts b/typescript/sdk/src/warp/WarpCore.ts index 389f3e19fbb..0baa625e0f9 100644 --- a/typescript/sdk/src/warp/WarpCore.ts +++ b/typescript/sdk/src/warp/WarpCore.ts @@ -768,55 +768,21 @@ export class WarpCore { senderPubKey?: HexString; }): Promise { const { token: originToken } = originTokenAmount; - const destinationName = this.multiProvider.getChainName(destination); const resolvedDestinationToken = this.resolveDestinationToken({ originToken, destination, destinationToken, }); - const originMetadata = this.multiProvider.getChainMetadata( - originToken.chainName, - ); - const localGasToken = Token.FromChainMetadataNativeToken(originMetadata); - - // Quote from contract (works for both same-chain and cross-chain) - assert( - originToken.collateralAddressOrDenom, - 'Origin token missing collateralAddressOrDenom', - ); - assert( - resolvedDestinationToken.addressOrDenom, - 'Destination token missing addressOrDenom', - ); - - const adapter = originToken.getHypAdapter( - this.multiProvider, - destinationName, - ) as EvmHypMultiCollateralAdapter; - - const destinationDomainId = this.multiProvider.getDomainId(destination); - const { igpQuote, tokenFeeQuote: rawTokenFeeQuote } = - await adapter.quoteTransferRemoteToGas({ - destination: destinationDomainId, + const { igpQuote: interchainQuote, tokenFeeQuote } = + await this.getInterchainTransferFee({ + originTokenAmount, + destination, + sender, recipient, - amount: originTokenAmount.amount, - targetRouter: resolvedDestinationToken.addressOrDenom, + destinationToken: resolvedDestinationToken, }); - let tokenFeeQuote: TokenAmount | undefined; - if (rawTokenFeeQuote?.amount) { - if ( - !rawTokenFeeQuote.addressOrDenom || - isZeroishAddress(rawTokenFeeQuote.addressOrDenom) - ) { - tokenFeeQuote = localGasToken.amount(rawTokenFeeQuote.amount); - } else { - tokenFeeQuote = originToken.amount(rawTokenFeeQuote.amount); - } - } - - const interchainQuote = localGasToken.amount(igpQuote.amount); const localQuote = await this.getLocalTransferFeeAmount({ originToken, destination, diff --git a/typescript/warp-monitor/src/monitor.test.ts b/typescript/warp-monitor/src/monitor.test.ts index 1e65d1e9ed2..70c51581636 100644 --- a/typescript/warp-monitor/src/monitor.test.ts +++ b/typescript/warp-monitor/src/monitor.test.ts @@ -15,13 +15,15 @@ import { } from './metrics.js'; import { WarpMonitor } from './monitor.js'; +type TokenLike = Pick; + function createMockToken({ collateralized, decimals, }: { collateralized: boolean; decimals: number; -}): Token { +}): TokenLike { return { isCollateralized: () => collateralized, amount: (amount: bigint) => ({ @@ -30,7 +32,7 @@ function createMockToken({ getAdapter: () => ({ getBalance: async () => 0n, }), - } as unknown as Token; + }; } async function invokeUpdatePendingAndInventoryMetrics( @@ -43,19 +45,19 @@ async function invokeUpdatePendingAndInventoryMetrics( explorerQueryLimit?: number, inventoryAddress?: string, ) { - await ( - monitor as unknown as { - updatePendingAndInventoryMetrics: ( - warpCore: WarpCore, - routerNodes: RouterNodeMetadata[], - collateralByNodeId: Map, - warpRouteId: string, - pendingTransfersClient?: ExplorerPendingTransfersClient, - explorerQueryLimit?: number, - inventoryAddress?: string, - ) => Promise; - } - ).updatePendingAndInventoryMetrics( + 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, @@ -96,7 +98,7 @@ describe('WarpMonitor', () => { token: createMockToken({ collateralized: true, decimals: 6, - }) as unknown as Token, + }) as Token, }, { nodeId: nonCollateralizedNodeId, @@ -110,7 +112,7 @@ describe('WarpMonitor', () => { token: createMockToken({ collateralized: false, decimals: 6, - }) as unknown as Token, + }) as Token, }, ]; From 37ca7360adb5cd403215797105018aff63e530e1 Mon Sep 17 00:00:00 2001 From: nambrot Date: Mon, 2 Mar 2026 17:55:19 -0500 Subject: [PATCH 16/26] fix: unblock CI by tightening monitor test token mocks --- typescript/sdk/src/warp/WarpCore.test.ts | 16 ++++-- typescript/warp-monitor/src/monitor.test.ts | 54 +++++++++------------ 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/typescript/sdk/src/warp/WarpCore.test.ts b/typescript/sdk/src/warp/WarpCore.test.ts index 93b81dbdc72..9f38b870ebc 100644 --- a/typescript/sdk/src/warp/WarpCore.test.ts +++ b/typescript/sdk/src/warp/WarpCore.test.ts @@ -378,8 +378,12 @@ describe('WarpCore', () => { Object.values(invalidCollateralXERC20LockboxToken || {})[0], ).to.equal('Insufficient collateral on destination'); - balanceStubs.forEach((s) => s.restore()); - quoteStubs.forEach((s) => s.restore()); + balanceStubs.forEach((s) => { + s.restore(); + }); + quoteStubs.forEach((s) => { + s.restore(); + }); }); it('Validates destination token routing', async () => { @@ -422,8 +426,12 @@ describe('WarpCore', () => { }); expect(validDestinationToken).to.be.null; - balanceStubs.forEach((s) => s.restore()); - quoteStubs.forEach((s) => s.restore()); + balanceStubs.forEach((s) => { + s.restore(); + }); + quoteStubs.forEach((s) => { + s.restore(); + }); }); it('Requires explicit destination token for ambiguous routes', async () => { diff --git a/typescript/warp-monitor/src/monitor.test.ts b/typescript/warp-monitor/src/monitor.test.ts index 70c51581636..9bd4e6adfca 100644 --- a/typescript/warp-monitor/src/monitor.test.ts +++ b/typescript/warp-monitor/src/monitor.test.ts @@ -15,24 +15,24 @@ import { } from './metrics.js'; import { WarpMonitor } from './monitor.js'; -type TokenLike = Pick; - function createMockToken({ collateralized, decimals, + getBalance = async () => 0n, }: { collateralized: boolean; decimals: number; -}): TokenLike { + getBalance?: () => Promise; +}): Token { return { isCollateralized: () => collateralized, - amount: (amount: bigint) => ({ + amount: ((amount: bigint) => ({ getDecimalFormattedAmount: () => Number(amount) / 10 ** decimals, - }), - getAdapter: () => ({ - getBalance: async () => 0n, - }), - }; + })) as Token['amount'], + getAdapter: (() => ({ + getBalance, + })) as unknown as Token['getAdapter'], + } as Token; } async function invokeUpdatePendingAndInventoryMetrics( @@ -98,7 +98,7 @@ describe('WarpMonitor', () => { token: createMockToken({ collateralized: true, decimals: 6, - }) as Token, + }), }, { nodeId: nonCollateralizedNodeId, @@ -112,7 +112,7 @@ describe('WarpMonitor', () => { token: createMockToken({ collateralized: false, decimals: 6, - }) as Token, + }), }, ]; @@ -214,17 +214,13 @@ describe('WarpMonitor', () => { tokenName: 'Collateral Token', tokenSymbol: 'COLLAT', tokenDecimals: 6, - token: { - isCollateralized: () => true, - amount: (amount: bigint) => ({ - getDecimalFormattedAmount: () => Number(amount) / 10 ** 6, - }), - getAdapter: () => ({ - getBalance: async () => { - throw new Error('rpc down'); - }, - }), - } as unknown as Token, + token: createMockToken({ + collateralized: true, + decimals: 6, + getBalance: async () => { + throw new Error('rpc down'); + }, + }), }, ]; @@ -279,15 +275,11 @@ describe('WarpMonitor', () => { tokenName: 'Collateral Token', tokenSymbol: 'COLLAT', tokenDecimals: 6, - token: { - isCollateralized: () => true, - amount: (amount: bigint) => ({ - getDecimalFormattedAmount: () => Number(amount) / 10 ** 6, - }), - getAdapter: () => ({ - getBalance: async () => 1_000_000n, - }), - } as unknown as Token, + token: createMockToken({ + collateralized: true, + decimals: 6, + getBalance: async () => 1_000_000n, + }), }, ]; From 70ec299c77fd02eb78a7714cb65e3548a51d16c4 Mon Sep 17 00:00:00 2001 From: nambrot Date: Mon, 2 Mar 2026 17:58:03 -0500 Subject: [PATCH 17/26] test: always restore multicollateral reader stubs --- .../token/EvmWarpRouteReader.hardhat-test.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/typescript/sdk/src/token/EvmWarpRouteReader.hardhat-test.ts b/typescript/sdk/src/token/EvmWarpRouteReader.hardhat-test.ts index 86ceb1cd157..159726c14e1 100644 --- a/typescript/sdk/src/token/EvmWarpRouteReader.hardhat-test.ts +++ b/typescript/sdk/src/token/EvmWarpRouteReader.hardhat-test.ts @@ -1054,23 +1054,25 @@ describe('EvmWarpRouteReader', async () => { const deriveMultiCollateralTokenConfig = (evmERC20WarpRouteReader as any) .deriveMultiCollateralTokenConfig as (address: string) => Promise; - 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], - }); + try { + const derivedConfig = await deriveMultiCollateralTokenConfig.call( + evmERC20WarpRouteReader, + routerAddress, + ); - mcConnectStub.restore(); - tokenRouterConnectStub.restore(); - metadataStub.restore(); - scaleStub.restore(); + 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', () => { From f0be3b5ec5aae86d21ee43f8043f26c7720c7d72 Mon Sep 17 00:00:00 2001 From: nambrot Date: Mon, 2 Mar 2026 18:11:18 -0500 Subject: [PATCH 18/26] fix(cli): format ratio scales in combine incompatibility logs --- typescript/cli/src/deploy/warp.test.ts | 67 +++++++++++++++++++++++++- typescript/cli/src/deploy/warp.ts | 6 ++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/typescript/cli/src/deploy/warp.test.ts b/typescript/cli/src/deploy/warp.test.ts index b87f10a9f3b..57f7c2a547b 100644 --- a/typescript/cli/src/deploy/warp.test.ts +++ b/typescript/cli/src/deploy/warp.test.ts @@ -31,7 +31,7 @@ function buildMultiCollateralToken({ symbol: string; address: string; decimals: number; - scale?: number; + scale?: number | { numerator: number; denominator: number }; }) { return { chainName, @@ -319,4 +319,69 @@ describe('runWarpRouteCombine', () => { '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 a8337d6c188..cffdf23177e 100644 --- a/typescript/cli/src/deploy/warp.ts +++ b/typescript/cli/src/deploy/warp.ts @@ -1267,7 +1267,11 @@ function formatScaleForLogs( scale: WarpCoreConfig['tokens'][number]['scale'], ): string { if (!scale) return '1'; - return scale.toString(); + const normalizedScale = normalizeScale(scale); + if (normalizedScale.denominator === 1n) { + return normalizedScale.numerator.toString(); + } + return `${normalizedScale.numerator}/${normalizedScale.denominator}`; } function getCanonicalWholeTokenRatio( From 23ddc77e6c821938c9c0e67fa74b689da35950c4 Mon Sep 17 00:00:00 2001 From: nambrot Date: Mon, 2 Mar 2026 20:24:00 -0500 Subject: [PATCH 19/26] fix multicollateral adapter wiring and monitor amount conversion --- solidity/eslint.config.mjs | 1 - typescript/sdk/src/token/Token.ts | 5 - .../EvmMultiCollateralAdapter.test.ts | 36 +++- .../adapters/EvmMultiCollateralAdapter.ts | 179 ++---------------- typescript/sdk/src/warp/WarpCore.test.ts | 89 +++++++++ typescript/sdk/src/warp/WarpCore.ts | 5 + typescript/warp-monitor/src/explorer.test.ts | 24 ++- typescript/warp-monitor/src/explorer.ts | 23 +-- typescript/warp-monitor/src/monitor.ts | 1 + 9 files changed, 170 insertions(+), 193 deletions(-) diff --git a/solidity/eslint.config.mjs b/solidity/eslint.config.mjs index 666e252de81..2676c8571e8 100644 --- a/solidity/eslint.config.mjs +++ b/solidity/eslint.config.mjs @@ -11,7 +11,6 @@ export default [ '**/typechain/**/*', '**/multicollateral/**/*', '**/dependencies/**/*', - '**/multicollateral/**/*', '.solcover.js', 'generate-artifact-exports.mjs', ], diff --git a/typescript/sdk/src/token/Token.ts b/typescript/sdk/src/token/Token.ts index 4b63ae76deb..ae744d9f943 100644 --- a/typescript/sdk/src/token/Token.ts +++ b/typescript/sdk/src/token/Token.ts @@ -266,13 +266,8 @@ export class Token implements IToken { token: addressOrDenom, }); } else if (standard === TokenStandard.EvmHypMultiCollateral) { - assert( - collateralAddressOrDenom, - 'collateralAddressOrDenom required for EvmHypMultiCollateral', - ); return new EvmHypMultiCollateralAdapter(chainName, multiProvider, { token: addressOrDenom, - collateralToken: collateralAddressOrDenom, }); } else if (standard === TokenStandard.EvmHypRebaseCollateral) { return new EvmHypRebaseCollateralAdapter(chainName, multiProvider, { diff --git a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts index eae142dcc1a..30eafd29ccb 100644 --- a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts +++ b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.test.ts @@ -35,7 +35,7 @@ describe('EvmHypMultiCollateralAdapter', () => { { amount: BigNumber.from('1500'), token: COLLATERAL_ADDRESS }, { amount: BigNumber.from('25'), token: COLLATERAL_ADDRESS }, ] as any); - (adapter as any).contract = { quoteTransferRemoteTo }; + (adapter as any).multiCollateralContract = { quoteTransferRemoteTo }; const quote = await adapter.quoteTransferRemoteToGas({ destination: DESTINATION_DOMAIN, @@ -55,7 +55,7 @@ describe('EvmHypMultiCollateralAdapter', () => { { amount: BigNumber.from('123456'), token: COLLATERAL_ADDRESS }, { amount: BigNumber.from('0'), token: COLLATERAL_ADDRESS }, ] as any); - (adapter as any).contract = { quoteTransferRemoteTo }; + (adapter as any).multiCollateralContract = { quoteTransferRemoteTo }; const quote = await adapter.quoteTransferRemoteToGas({ destination: DESTINATION_DOMAIN, @@ -76,7 +76,7 @@ describe('EvmHypMultiCollateralAdapter', () => { { amount: BigNumber.from('1500'), token: COLLATERAL_ADDRESS }, { amount: BigNumber.from('10'), token: COLLATERAL_ADDRESS }, ] as any); - (adapter as any).contract = { quoteTransferRemoteTo }; + (adapter as any).multiCollateralContract = { quoteTransferRemoteTo }; const quote = await adapter.quoteTransferRemoteToGas({ destination: DESTINATION_DOMAIN, @@ -97,7 +97,7 @@ describe('EvmHypMultiCollateralAdapter', () => { { amount: BigNumber.from('10'), token: COLLATERAL_ADDRESS }, ] as any); const transferRemoteTo = sinon.stub().resolves({}); - (adapter as any).contract = { + (adapter as any).multiCollateralContract = { quoteTransferRemoteTo, populateTransaction: { transferRemoteTo }, }; @@ -124,7 +124,7 @@ describe('EvmHypMultiCollateralAdapter', () => { { amount: BigNumber.from('10'), token: COLLATERAL_ADDRESS }, ] as any); const transferRemoteTo = sinon.stub().resolves({}); - (adapter as any).contract = { + (adapter as any).multiCollateralContract = { quoteTransferRemoteTo, populateTransaction: { transferRemoteTo }, }; @@ -140,4 +140,30 @@ describe('EvmHypMultiCollateralAdapter', () => { 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 index 05e4989dbf6..84ccca18d9c 100644 --- a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts +++ b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts @@ -1,6 +1,5 @@ import { PopulatedTransaction } from 'ethers'; -import { ERC20__factory } from '@hyperlane-xyz/core'; import { MultiCollateral, MultiCollateral__factory, @@ -11,186 +10,36 @@ import { Numberish, addressToBytes32, assert, + isAddressEvm, isZeroishAddress, } from '@hyperlane-xyz/utils'; -import { BaseEvmAdapter } from '../../app/MultiProtocolApp.js'; import { MultiProtocolProvider } from '../../providers/MultiProtocolProvider.js'; import { ChainName } from '../../types.js'; -import { TokenMetadata } from '../types.js'; -import { - IHypTokenAdapter, - InterchainGasQuote, - QuoteTransferRemoteParams, - TransferParams, - TransferRemoteParams, -} from './ITokenAdapter.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 BaseEvmAdapter - implements IHypTokenAdapter -{ - public readonly contract: MultiCollateral; - public readonly collateralToken: Address; +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; // underlying ERC20 + collateralToken?: Address; // optional hint for callers; resolved onchain }, ) { - super(chainName, multiProvider, addresses); - this.contract = MultiCollateral__factory.connect( + super(chainName, multiProvider, { token: addresses.token }); + this.multiCollateralContract = MultiCollateral__factory.connect( addresses.token, this.getProvider(), ); - this.collateralToken = addresses.collateralToken; - } - - async getBalance(address: Address): Promise { - const erc20 = ERC20__factory.connect( - this.collateralToken, - this.getProvider(), - ); - const balance = await erc20.balanceOf(address); - return BigInt(balance.toString()); - } - - async getMetadata(): Promise { - const erc20 = ERC20__factory.connect( - this.collateralToken, - this.getProvider(), - ); - const [decimals, symbol, name] = await Promise.all([ - erc20.decimals(), - erc20.symbol(), - erc20.name(), - ]); - return { decimals, symbol, name }; - } - - async getMinimumTransferAmount(_recipient: Address): Promise { - return 0n; - } - - async getTotalSupply(): Promise { - const erc20 = ERC20__factory.connect( - this.collateralToken, - this.getProvider(), - ); - const totalSupply = await erc20.totalSupply(); - return BigInt(totalSupply.toString()); - } - - async isApproveRequired( - owner: Address, - _spender: Address, - weiAmountOrId: Numberish, - ): Promise { - const erc20 = ERC20__factory.connect( - this.collateralToken, - this.getProvider(), - ); - const allowance = await erc20.allowance(owner, this.addresses.token); - return allowance.lt(weiAmountOrId); - } - - async isRevokeApprovalRequired( - owner: Address, - _spender: Address, - ): Promise { - const erc20 = ERC20__factory.connect( - this.collateralToken, - this.getProvider(), - ); - const allowance = await erc20.allowance(owner, this.addresses.token); - return !allowance.isZero(); - } - - async populateApproveTx({ - weiAmountOrId, - }: TransferParams): Promise { - const erc20 = ERC20__factory.connect( - this.collateralToken, - this.getProvider(), - ); - return erc20.populateTransaction.approve( - this.addresses.token, - weiAmountOrId.toString(), - ); - } - - async populateTransferTx({ - weiAmountOrId, - recipient, - }: TransferParams): Promise { - const erc20 = ERC20__factory.connect( - this.collateralToken, - this.getProvider(), - ); - return erc20.populateTransaction.transfer( - recipient, - weiAmountOrId.toString(), - ); - } - - async getDomains(): Promise { - const domains = await this.contract.domains(); - return domains.map(Number); - } - - async getRouterAddress(domain: Domain): Promise { - const router = await this.contract.routers(domain); - return Buffer.from(router.slice(2), 'hex'); - } - - async getAllRouters(): Promise> { - const domains = await this.getDomains(); - return Promise.all( - domains.map(async (domain) => ({ - domain, - address: await this.getRouterAddress(domain), - })), - ); - } - - async getBridgedSupply(): Promise { - const erc20 = ERC20__factory.connect( - this.collateralToken, - this.getProvider(), - ); - const balance = await erc20.balanceOf(this.addresses.token); - return BigInt(balance.toString()); - } - - // Standard transferRemote (same-stablecoin, uses enrolled remote router) - async populateTransferRemoteTx( - params: TransferRemoteParams, - ): Promise { - const recipientBytes32 = addressToBytes32(params.recipient); - const gasPayment = await this.contract.quoteGasPayment(params.destination); - return this.contract.populateTransaction.transferRemote( - params.destination, - recipientBytes32, - params.weiAmountOrId.toString(), - { value: gasPayment }, - ); - } - - async quoteTransferRemoteGas( - params: QuoteTransferRemoteParams, - ): Promise { - const gasPayment = await this.contract.quoteGasPayment(params.destination); - return { - igpQuote: { amount: BigInt(gasPayment.toString()) }, - }; } // ============ MultiCollateral-specific methods ============ @@ -207,7 +56,7 @@ export class EvmHypMultiCollateralAdapter const recipientBytes32 = addressToBytes32(params.recipient); const targetRouterBytes32 = addressToBytes32(params.targetRouter); - return this.contract.quoteTransferRemoteTo( + return this.multiCollateralContract.quoteTransferRemoteTo( params.destination, recipientBytes32, params.amount.toString(), @@ -228,7 +77,7 @@ export class EvmHypMultiCollateralAdapter ? quote.igpQuote.amount.toString() : '0'; - return this.contract.populateTransaction.transferRemoteTo( + return this.multiCollateralContract.populateTransaction.transferRemoteTo( params.destination, recipientBytes32, params.amount.toString(), @@ -251,6 +100,14 @@ export class EvmHypMultiCollateralAdapter 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()); diff --git a/typescript/sdk/src/warp/WarpCore.test.ts b/typescript/sdk/src/warp/WarpCore.test.ts index 9f38b870ebc..15f02c9e8f5 100644 --- a/typescript/sdk/src/warp/WarpCore.test.ts +++ b/typescript/sdk/src/warp/WarpCore.test.ts @@ -580,6 +580,95 @@ describe('WarpCore', () => { } }); + 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; diff --git a/typescript/sdk/src/warp/WarpCore.ts b/typescript/sdk/src/warp/WarpCore.ts index 0baa625e0f9..b4cf9707579 100644 --- a/typescript/sdk/src/warp/WarpCore.ts +++ b/typescript/sdk/src/warp/WarpCore.ts @@ -634,6 +634,11 @@ export class WarpCore { 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; diff --git a/typescript/warp-monitor/src/explorer.test.ts b/typescript/warp-monitor/src/explorer.test.ts index 1f1b9d61465..5eb16767f5d 100644 --- a/typescript/warp-monitor/src/explorer.test.ts +++ b/typescript/warp-monitor/src/explorer.test.ts @@ -5,7 +5,7 @@ import { rootLogger } from '@hyperlane-xyz/utils'; import { ExplorerPendingTransfersClient, - canonical18ToTokenBaseUnits, + messageAmountToTokenBaseUnits, normalizeExplorerAddress, normalizeExplorerHex, type RouterNodeMetadata, @@ -30,15 +30,22 @@ describe('Explorer Pending Transfers', () => { ); }); - it('converts canonical18 amount to token base units', () => { - const canonicalAmount = 1234567890000000000n; - expect(canonical18ToTokenBaseUnits(canonicalAmount, 18)).to.equal( - canonicalAmount, + it('converts message amount to token base units using scale', () => { + const messageAmount = 1234567890000000000n; + expect(messageAmountToTokenBaseUnits(messageAmount, 1)).to.equal( + messageAmount, ); - expect(canonical18ToTokenBaseUnits(canonicalAmount, 6)).to.equal( - 1234567n, + 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', ); - expect(canonical18ToTokenBaseUnits(1n, 20)).to.equal(100n); }); }); @@ -54,6 +61,7 @@ describe('Explorer Pending Transfers', () => { tokenName: 'USD Coin', tokenSymbol: 'USDC', tokenDecimals: 6, + tokenScale: 1_000_000_000_000, token: {} as any, }, ]; diff --git a/typescript/warp-monitor/src/explorer.ts b/typescript/warp-monitor/src/explorer.ts index bfa4b992ffa..f5785838bbb 100644 --- a/typescript/warp-monitor/src/explorer.ts +++ b/typescript/warp-monitor/src/explorer.ts @@ -8,8 +8,6 @@ import { parseWarpRouteMessage, } from '@hyperlane-xyz/utils'; -const CANONICAL_DECIMALS = 18; - type ExplorerMessageRow = { msg_id: string; origin_domain_id: number; @@ -29,6 +27,7 @@ export type RouterNodeMetadata = { tokenName: string; tokenSymbol: string; tokenDecimals: number; + tokenScale?: number; token: Token; }; @@ -70,18 +69,16 @@ function isValidEvmWarpRecipient(recipientBytes32: string): boolean { } } -export function canonical18ToTokenBaseUnits( - amountCanonical18: bigint, - tokenDecimals: number, +export function messageAmountToTokenBaseUnits( + amountMessageUnits: bigint, + tokenScale?: number, ): bigint { - if (tokenDecimals === CANONICAL_DECIMALS) return amountCanonical18; - if (tokenDecimals < CANONICAL_DECIMALS) { - const divisor = 10n ** BigInt(CANONICAL_DECIMALS - tokenDecimals); - return amountCanonical18 / divisor; + const scale = BigInt(tokenScale ?? 1); + if (scale <= 0n) { + throw new Error(`Invalid token scale ${scale.toString()}`); } - const multiplier = 10n ** BigInt(tokenDecimals - CANONICAL_DECIMALS); - return amountCanonical18 * multiplier; + return amountMessageUnits / scale; } export class ExplorerPendingTransfersClient { @@ -160,9 +157,9 @@ export class ExplorerPendingTransfersClient { destinationChain: node.chainName, destinationNodeId: node.nodeId, destinationRouter, - amountBaseUnits: canonical18ToTokenBaseUnits( + amountBaseUnits: messageAmountToTokenBaseUnits( parsedMessage.amount, - node.tokenDecimals, + node.tokenScale, ), sendOccurredAtMs, }); diff --git a/typescript/warp-monitor/src/monitor.ts b/typescript/warp-monitor/src/monitor.ts index a711fe3c51f..f547228253e 100644 --- a/typescript/warp-monitor/src/monitor.ts +++ b/typescript/warp-monitor/src/monitor.ts @@ -593,6 +593,7 @@ export class WarpMonitor { tokenName: token.name, tokenSymbol: token.symbol, tokenDecimals: token.decimals, + tokenScale: token.scale ?? 1, token, }); } From 0edf9dd478a13a2e45944068705da8eacd33c162 Mon Sep 17 00:00:00 2001 From: nambrot Date: Mon, 2 Mar 2026 21:54:48 -0500 Subject: [PATCH 20/26] chore(multicollateral): align tsconfig with main --- solidity/multicollateral/tsconfig.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/solidity/multicollateral/tsconfig.json b/solidity/multicollateral/tsconfig.json index 6398e9ce519..d5ed038c64e 100644 --- a/solidity/multicollateral/tsconfig.json +++ b/solidity/multicollateral/tsconfig.json @@ -1,6 +1,12 @@ { "extends": "@hyperlane-xyz/tsconfig/tsconfig.json", "compilerOptions": { + "target": "ES2020", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, "outDir": "./dist", "declaration": true }, From 4ee193475f68b8d1c23564eb2d16fa7b7f6fdb6f Mon Sep 17 00:00:00 2001 From: nambrot Date: Tue, 3 Mar 2026 12:22:57 -0500 Subject: [PATCH 21/26] fix: address PR review feedback from paulbalaji - Accept optional pre-fetched interchainGas in populateTransferRemoteToTx to avoid double-quoting - Remove unnecessary non-null assertions on addressOrDenom in WarpCore MC path - Make isMultiCollateralTransfer a type guard to narrow destinationToken - Replace magic number 42 with isAddressEvm in toCanonicalRouterId - Assert addressOrDenom before use in combine instead of ! - Validate outputId is non-empty in combine handler - Assert no duplicate tokens before writing merged config - Use instanceof Error narrowing in explorer.ts catch block --- typescript/cli/src/commands/warp.ts | 4 ++++ typescript/cli/src/deploy/warp.ts | 13 ++++++++++++- typescript/sdk/src/token/EvmWarpModule.ts | 3 ++- .../token/adapters/EvmMultiCollateralAdapter.ts | 4 +++- typescript/sdk/src/warp/WarpCore.ts | 17 +++++++---------- typescript/warp-monitor/src/explorer.ts | 2 +- 6 files changed, 29 insertions(+), 14 deletions(-) diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index 19e9f9aa8b0..5aef632866a 100644 --- a/typescript/cli/src/commands/warp.ts +++ b/typescript/cli/src/commands/warp.ts @@ -219,6 +219,10 @@ const combine: CommandModuleWithWriteContext<{ 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, diff --git a/typescript/cli/src/deploy/warp.ts b/typescript/cli/src/deploy/warp.ts index cffdf23177e..8cbfcd9b63d 100644 --- a/typescript/cli/src/deploy/warp.ts +++ b/typescript/cli/src/deploy/warp.ts @@ -1396,7 +1396,11 @@ export async function runWarpRouteCombine({ const otherDomain = context.multiProvider .getDomainId(otherToken.chainName) .toString(); - const otherRouter = addressToBytes32(otherToken.addressOrDenom!); + assert( + otherToken.addressOrDenom, + `MultiCollateral token missing addressOrDenom on ${otherToken.chainName}`, + ); + const otherRouter = addressToBytes32(otherToken.addressOrDenom); enrolledRouters[otherDomain] ??= new Set(); enrolledRouters[otherDomain].add(otherRouter); @@ -1443,9 +1447,16 @@ export async function runWarpRouteCombine({ // 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: [] }); } } diff --git a/typescript/sdk/src/token/EvmWarpModule.ts b/typescript/sdk/src/token/EvmWarpModule.ts index 1a77a0c20c9..c5fedd23bf4 100644 --- a/typescript/sdk/src/token/EvmWarpModule.ts +++ b/typescript/sdk/src/token/EvmWarpModule.ts @@ -25,6 +25,7 @@ import { deepEquals, difference, eqAddress, + isAddressEvm, isNullish, isObjEmpty, isZeroishAddress, @@ -545,7 +546,7 @@ export class EvmWarpModule extends HyperlaneModule< } private toCanonicalRouterId(router: string): string { - if (router.length === 42) { + if (isAddressEvm(router)) { return addressToBytes32(router.toLowerCase()); } return router.toLowerCase(); diff --git a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts index 84ccca18d9c..c6ac2fc8f03 100644 --- a/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts +++ b/typescript/sdk/src/token/adapters/EvmMultiCollateralAdapter.ts @@ -69,10 +69,12 @@ export class EvmHypMultiCollateralAdapter extends EvmHypCollateralAdapter { recipient: Address; amount: Numberish; targetRouter: Address; + interchainGas?: InterchainGasQuote; }): Promise { const recipientBytes32 = addressToBytes32(params.recipient); const targetRouterBytes32 = addressToBytes32(params.targetRouter); - const quote = await this.quoteTransferRemoteToGas(params); + const quote = + params.interchainGas ?? (await this.quoteTransferRemoteToGas(params)); const nativeGas = !quote.igpQuote.addressOrDenom ? quote.igpQuote.amount.toString() : '0'; diff --git a/typescript/sdk/src/warp/WarpCore.ts b/typescript/sdk/src/warp/WarpCore.ts index b4cf9707579..917efb68f3f 100644 --- a/typescript/sdk/src/warp/WarpCore.ts +++ b/typescript/sdk/src/warp/WarpCore.ts @@ -577,7 +577,7 @@ export class WarpCore { protected isMultiCollateralTransfer( originToken: IToken, destinationToken?: IToken, - ): boolean { + ): destinationToken is IToken { if (!destinationToken) return false; return ( originToken.isMultiCollateralToken() && @@ -643,18 +643,14 @@ export class WarpCore { const totalDebit = amount + tokenFeeAmount; const [isApproveRequired, isRevokeApprovalRequired] = await Promise.all([ - adapter.isApproveRequired( - sender, - originToken.addressOrDenom!, - totalDebit, - ), - adapter.isRevokeApprovalRequired(sender, originToken.addressOrDenom!), + adapter.isApproveRequired(sender, originToken.addressOrDenom, totalDebit), + adapter.isRevokeApprovalRequired(sender, originToken.addressOrDenom), ]); if (isApproveRequired && isRevokeApprovalRequired) { const revokeTxReq = await adapter.populateApproveTx({ weiAmountOrId: 0, - recipient: originToken.addressOrDenom!, + recipient: originToken.addressOrDenom, }); transactions.push({ category: WarpTxCategory.Revoke, @@ -666,7 +662,7 @@ export class WarpCore { if (isApproveRequired) { const approveTxReq = await adapter.populateApproveTx({ weiAmountOrId: totalDebit, - recipient: originToken.addressOrDenom!, + recipient: originToken.addressOrDenom, }); transactions.push({ category: WarpTxCategory.Approval, @@ -684,6 +680,7 @@ export class WarpCore { recipient, amount, targetRouter: resolvedDestinationToken.addressOrDenom, + interchainGas: transferQuote, }); transactions.push({ category: WarpTxCategory.Transfer, @@ -721,7 +718,7 @@ export class WarpCore { return this.estimateMultiCollateralFees({ originTokenAmount, destination, - destinationToken: destinationToken!, + destinationToken, recipient, sender, senderPubKey, diff --git a/typescript/warp-monitor/src/explorer.ts b/typescript/warp-monitor/src/explorer.ts index f5785838bbb..4315269b5ae 100644 --- a/typescript/warp-monitor/src/explorer.ts +++ b/typescript/warp-monitor/src/explorer.ts @@ -129,7 +129,7 @@ export class ExplorerPendingTransfersClient { messageId: row.msg_id, destinationDomainId: row.destination_domain_id, destinationRouter, - error: (error as Error).message, + error: error instanceof Error ? error.message : String(error), }, 'Skipping explorer message with unparsable warp message body', ); From 406c680594b6ebbd53fbf0fc136a4553ad92c69b Mon Sep 17 00:00:00 2001 From: nambrot Date: Tue, 3 Mar 2026 12:23:00 -0500 Subject: [PATCH 22/26] rebalancer-sim: package scenarios and support SCENARIOS_DIR override --- typescript/rebalancer-sim/package.json | 3 +- .../rebalancer-sim/src/ScenarioLoader.ts | 31 ++++-- .../test/scenarios/scenario-loader.test.ts | 105 ++++++++++++++++++ 3 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 typescript/rebalancer-sim/test/scenarios/scenario-loader.test.ts 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/ScenarioLoader.ts b/typescript/rebalancer-sim/src/ScenarioLoader.ts index 23f2551eb0c..84f1b0f1dd8 100644 --- a/typescript/rebalancer-sim/src/ScenarioLoader.ts +++ b/typescript/rebalancer-sim/src/ScenarioLoader.ts @@ -7,17 +7,32 @@ 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`); + 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 +70,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/test/scenarios/scenario-loader.test.ts b/typescript/rebalancer-sim/test/scenarios/scenario-loader.test.ts new file mode 100644 index 00000000000..127bc9cf14f --- /dev/null +++ b/typescript/rebalancer-sim/test/scenarios/scenario-loader.test.ts @@ -0,0 +1,105 @@ +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']; + + afterEach(() => { + 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', () => { + const 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)); + }); +}); From 03e2cac2bbdabf59f30bd93c965300b8f065c9c1 Mon Sep 17 00:00:00 2001 From: nambrot Date: Tue, 3 Mar 2026 12:34:35 -0500 Subject: [PATCH 23/26] rebalancer-sim: add shared result exporter API --- .../rebalancer-sim/src/ResultsExporter.ts | 129 ++++++++++++++++++ typescript/rebalancer-sim/src/index.ts | 6 + .../test/utils/simulation-helpers.ts | 98 +------------ 3 files changed, 142 insertions(+), 91 deletions(-) create mode 100644 typescript/rebalancer-sim/src/ResultsExporter.ts diff --git a/typescript/rebalancer-sim/src/ResultsExporter.ts b/typescript/rebalancer-sim/src/ResultsExporter.ts new file mode 100644 index 00000000000..6559daa2456 --- /dev/null +++ b/typescript/rebalancer-sim/src/ResultsExporter.ts @@ -0,0 +1,129 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { ethers } from 'ethers'; + +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; + + 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/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/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}`); } From 6532996a29e0c62fd23bba29893731c609352b46 Mon Sep 17 00:00:00 2001 From: nambrot Date: Tue, 3 Mar 2026 13:59:01 -0500 Subject: [PATCH 24/26] fix: add path traversal guards and temp dir cleanup in rebalancer-sim --- typescript/rebalancer-sim/src/ResultsExporter.ts | 13 +++++++++++-- typescript/rebalancer-sim/src/ScenarioLoader.ts | 5 +++++ .../test/scenarios/scenario-loader.test.ts | 7 ++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/typescript/rebalancer-sim/src/ResultsExporter.ts b/typescript/rebalancer-sim/src/ResultsExporter.ts index 6559daa2456..870109b85a5 100644 --- a/typescript/rebalancer-sim/src/ResultsExporter.ts +++ b/typescript/rebalancer-sim/src/ResultsExporter.ts @@ -2,6 +2,7 @@ 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'; @@ -27,7 +28,13 @@ export type SaveSimulationResultsOutput = { export function saveSimulationResults( options: SaveSimulationResultsOptions, ): SaveSimulationResultsOutput { - const { outputDir, scenarioName, scenarioFile, results, comparison } = options; + const { outputDir, scenarioName, scenarioFile, results, comparison } = + options; + + assert( + !scenarioName.includes('..') && !path.isAbsolute(scenarioName), + `Invalid scenario name: ${scenarioName}`, + ); fs.mkdirSync(outputDir, { recursive: true }); @@ -109,7 +116,9 @@ export function saveSimulationResults( ethers.utils.formatEther(scenarioFile.defaultInitialCollateral), ); const extra = scenarioFile.initialImbalance?.[chain] - ? parseFloat(ethers.utils.formatEther(scenarioFile.initialImbalance[chain])) + ? parseFloat( + ethers.utils.formatEther(scenarioFile.initialImbalance[chain]), + ) : 0; vizConfig.initialCollateral[chain] = (base + extra).toString(); } diff --git a/typescript/rebalancer-sim/src/ScenarioLoader.ts b/typescript/rebalancer-sim/src/ScenarioLoader.ts index 84f1b0f1dd8..f352617d0fe 100644 --- a/typescript/rebalancer-sim/src/ScenarioLoader.ts +++ b/typescript/rebalancer-sim/src/ScenarioLoader.ts @@ -2,6 +2,7 @@ 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'; @@ -27,6 +28,10 @@ function resolveScenariosDir(): string { * Load a scenario file (full format with metadata and defaults) */ export function loadScenarioFile(name: string): ScenarioFile { + assert( + !name.includes('..') && !path.isAbsolute(name), + `Invalid scenario name: ${name}`, + ); const scenariosDir = resolveScenariosDir(); const filePath = path.join(scenariosDir, `${name}.json`); diff --git a/typescript/rebalancer-sim/test/scenarios/scenario-loader.test.ts b/typescript/rebalancer-sim/test/scenarios/scenario-loader.test.ts index 127bc9cf14f..9340e80df52 100644 --- a/typescript/rebalancer-sim/test/scenarios/scenario-loader.test.ts +++ b/typescript/rebalancer-sim/test/scenarios/scenario-loader.test.ts @@ -13,8 +13,13 @@ import { 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 { @@ -37,7 +42,7 @@ describe('ScenarioLoader', () => { }); it('supports SCENARIOS_DIR override', () => { - const customDir = fs.mkdtempSync( + customDir = fs.mkdtempSync( path.join(os.tmpdir(), 'rebalancer-sim-scenarios-'), ); From 70e12c844ad2ff1111074a23dba575e20ffade5e Mon Sep 17 00:00:00 2001 From: nambrot Date: Tue, 3 Mar 2026 14:12:13 -0500 Subject: [PATCH 25/26] fix: lowercase router before isAddressEvm check in toCanonicalRouterId --- typescript/sdk/src/token/EvmWarpModule.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/typescript/sdk/src/token/EvmWarpModule.ts b/typescript/sdk/src/token/EvmWarpModule.ts index c5fedd23bf4..4959f476d2c 100644 --- a/typescript/sdk/src/token/EvmWarpModule.ts +++ b/typescript/sdk/src/token/EvmWarpModule.ts @@ -546,10 +546,11 @@ export class EvmWarpModule extends HyperlaneModule< } private toCanonicalRouterId(router: string): string { - if (isAddressEvm(router)) { - return addressToBytes32(router.toLowerCase()); + const lower = router.toLowerCase(); + if (isAddressEvm(lower)) { + return addressToBytes32(lower); } - return router.toLowerCase(); + return lower; } async getAllowedBridgesApprovalTxs( From a7ff614773baa8df44799b7cff50f06fadbd14d5 Mon Sep 17 00:00:00 2001 From: nambrot Date: Wed, 4 Mar 2026 07:35:40 -0500 Subject: [PATCH 26/26] chore: add changesets for multicollateral SDK/CLI/monitor and rebalancer-sim --- .changeset/multicollateral-sdk-cli-monitor.md | 13 +++++++++++++ .changeset/rebalancer-sim-exporter.md | 5 +++++ 2 files changed, 18 insertions(+) create mode 100644 .changeset/multicollateral-sdk-cli-monitor.md create mode 100644 .changeset/rebalancer-sim-exporter.md 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.