diff --git a/.changeset/multiprotocol-core-rebalancer.md b/.changeset/multiprotocol-core-rebalancer.md new file mode 100644 index 00000000000..99d6df21b0f --- /dev/null +++ b/.changeset/multiprotocol-core-rebalancer.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/rebalancer': patch +--- + +ActionTracker now uses MultiProtocolCore instead of HyperlaneCore for message delivery checks, enabling support for all VM types. Registry addresses are validated at startup to ensure mailbox is present. diff --git a/typescript/rebalancer/src/factories/RebalancerContextFactory.ts b/typescript/rebalancer/src/factories/RebalancerContextFactory.ts index ae5ab53205b..02a6453588b 100644 --- a/typescript/rebalancer/src/factories/RebalancerContextFactory.ts +++ b/typescript/rebalancer/src/factories/RebalancerContextFactory.ts @@ -3,7 +3,8 @@ import { type Logger } from 'pino'; import { IRegistry } from '@hyperlane-xyz/registry'; import { type ChainMap, - HyperlaneCore, + type CoreAddresses, + MultiProtocolCore, MultiProtocolProvider, MultiProvider, type Token, @@ -45,6 +46,7 @@ export class RebalancerContextFactory { * @param warpCore - An instance of `WarpCore` configured for the specified `warpRouteId`. * @param tokensByChainName - A map of chain->token to ease the lookup of token by chain * @param multiProvider - MultiProvider instance + * @param multiProtocolProvider - MultiProtocolProvider instance (with mailbox metadata) * @param registry - IRegistry instance * @param logger - Logger instance */ @@ -53,6 +55,7 @@ export class RebalancerContextFactory { private readonly warpCore: WarpCore, private readonly tokensByChainName: ChainMap, private readonly multiProvider: MultiProvider, + private readonly multiProtocolProvider: MultiProtocolProvider, private readonly registry: IRegistry, private readonly logger: Logger, ) {} @@ -87,7 +90,7 @@ export class RebalancerContextFactory { const mpp = multiProtocolProvider ?? MultiProtocolProvider.fromMultiProvider(multiProvider); - const provider = mpp.extendChainMetadata(mailboxes); + const extendedMultiProtocolProvider = mpp.extendChainMetadata(mailboxes); const warpCoreConfig = await registry.getWarpRoute(config.warpRouteId); if (!warpCoreConfig) { @@ -95,7 +98,10 @@ export class RebalancerContextFactory { `Warp route config for ${config.warpRouteId} not found in registry`, ); } - const warpCore = WarpCore.FromConfig(provider, warpCoreConfig); + const warpCore = WarpCore.FromConfig( + extendedMultiProtocolProvider, + warpCoreConfig, + ); const tokensByChainName = Object.fromEntries( warpCore.tokens.map((t) => [t.chainName, t]), ); @@ -111,6 +117,7 @@ export class RebalancerContextFactory { warpCore, tokensByChainName, multiProvider, + extendedMultiProtocolProvider, registry, logger, ); @@ -228,11 +235,24 @@ export class RebalancerContextFactory { // 2. Create ExplorerClient const explorerClient = new ExplorerClient(explorerUrl); - // 3. Get HyperlaneCore from registry - const addresses = await this.registry.getAddresses(); - const hyperlaneCore = HyperlaneCore.fromAddressesMap( - addresses, - this.multiProvider, + // 3. Get MultiProtocolCore from registry (supports all VM types) + // Only fetch/validate addresses for warp route chains (not all registry chains) + const warpRouteChains = new Set( + this.warpCore.tokens.map((t) => t.chainName), + ); + const coreAddresses: ChainMap = {}; + for (const chain of warpRouteChains) { + const addrs = await this.registry.getChainAddresses(chain); + if (!addrs?.mailbox) { + throw new Error( + `Missing mailbox address for chain ${chain} in registry`, + ); + } + coreAddresses[chain] = addrs as CoreAddresses; + } + const multiProtocolCore = MultiProtocolCore.fromAddressesMap( + coreAddresses, + this.multiProtocolProvider, ); // 4. Get rebalancer address from signer @@ -265,7 +285,7 @@ export class RebalancerContextFactory { intentStore, actionStore, explorerClient, - hyperlaneCore, + multiProtocolCore, trackerConfig, this.logger, ); diff --git a/typescript/rebalancer/src/monitor/Monitor.ts b/typescript/rebalancer/src/monitor/Monitor.ts index 4c01692c77a..aa729eab00e 100644 --- a/typescript/rebalancer/src/monitor/Monitor.ts +++ b/typescript/rebalancer/src/monitor/Monitor.ts @@ -1,10 +1,6 @@ import { type Logger } from 'pino'; -import { - EthJsonRpcBlockParameterTag, - type Token, - type WarpCore, -} from '@hyperlane-xyz/sdk'; +import { type Token, type WarpCore } from '@hyperlane-xyz/sdk'; import { sleep } from '@hyperlane-xyz/utils'; import { @@ -16,6 +12,7 @@ import { MonitorPollingError, MonitorStartError, } from '../interfaces/IMonitor.js'; +import { getConfirmedBlockTag } from '../utils/blockTag.js'; /** * Simple monitor implementation that polls warp route collateral balances and emits them as MonitorEvent. @@ -38,36 +35,16 @@ export class Monitor implements IMonitor { private readonly logger: Logger, ) {} - private async getConfirmedBlockTag( - chainName: string, - ): Promise { - try { - const metadata = this.warpCore.multiProvider.getChainMetadata(chainName); - const reorgPeriod = metadata.blocks?.reorgPeriod ?? 32; - - if (typeof reorgPeriod === 'string') { - return reorgPeriod as EthJsonRpcBlockParameterTag; - } - - const provider = - this.warpCore.multiProvider.getEthersV5Provider(chainName); - const latestBlock = await provider.getBlockNumber(); - return Math.max(0, latestBlock - reorgPeriod); - } catch (error) { - this.logger.warn( - { chain: chainName, error: (error as Error).message }, - 'Failed to get confirmed block, using latest', - ); - return undefined; - } - } - private async computeConfirmedBlockTags(): Promise { const blockTags: ConfirmedBlockTags = {}; const chains = new Set(this.warpCore.tokens.map((t) => t.chainName)); for (const chain of chains) { - blockTags[chain] = await this.getConfirmedBlockTag(chain); + blockTags[chain] = await getConfirmedBlockTag( + this.warpCore.multiProvider, + chain, + this.logger, + ); } return blockTags; diff --git a/typescript/rebalancer/src/tracking/ActionTracker.test.ts b/typescript/rebalancer/src/tracking/ActionTracker.test.ts index 5de4939faea..dad0b1b6f22 100644 --- a/typescript/rebalancer/src/tracking/ActionTracker.test.ts +++ b/typescript/rebalancer/src/tracking/ActionTracker.test.ts @@ -5,7 +5,6 @@ import Sinon from 'sinon'; import { EthJsonRpcBlockParameterTag } from '@hyperlane-xyz/sdk'; -import type { ConfirmedBlockTags } from '../interfaces/IMonitor.js'; import type { ExplorerMessage } from '../utils/ExplorerClient.js'; import { ActionTracker, type ActionTrackerConfig } from './ActionTracker.js'; @@ -46,19 +45,19 @@ describe('ActionTracker', () => { getInflightRebalanceActions: explorerGetInflightRebalanceActions, } as any; - // Create stub for mailbox + // Create stub for adapter (used by MultiProtocolCore) mailboxStub = { - delivered: Sinon.stub().resolves(false), + isDelivered: Sinon.stub().resolves(false), }; - // Create stub for HyperlaneCore - const coreGetContracts = Sinon.stub().returns({ mailbox: mailboxStub }); + // Create stub for MultiProtocolCore const multiProviderGetChainName = Sinon.stub().callsFake( (domain: number) => `chain${domain}`, ); + const adapterStub = Sinon.stub().returns(mailboxStub); core = { - getContracts: coreGetContracts, + adapter: adapterStub, multiProvider: { getChainName: multiProviderGetChainName, }, @@ -107,7 +106,7 @@ describe('ActionTracker', () => { explorerClient.getInflightUserTransfers.resolves([]); // Ensure mailbox returns false so action stays in_progress - mailboxStub.delivered.resolves(false); + mailboxStub.isDelivered.resolves(false); await tracker.initialize(); @@ -259,7 +258,7 @@ describe('ActionTracker', () => { }); explorerClient.getInflightUserTransfers.resolves([]); - mailboxStub.delivered.resolves(true); + mailboxStub.isDelivered.resolves(true); await tracker.syncTransfers(); @@ -338,7 +337,7 @@ describe('ActionTracker', () => { await rebalanceIntentStore.save(intent); await rebalanceActionStore.save(action); - mailboxStub.delivered.resolves(true); + mailboxStub.isDelivered.resolves(true); await tracker.syncRebalanceActions(); @@ -367,7 +366,7 @@ describe('ActionTracker', () => { await rebalanceActionStore.save(action); - mailboxStub.delivered.resolves(false); + mailboxStub.isDelivered.resolves(false); await tracker.syncRebalanceActions(); @@ -658,8 +657,8 @@ describe('ActionTracker', () => { }); }); - describe('confirmedBlockTags synchronization', () => { - it('should use provided blockTag in syncTransfers delivery check', async () => { + describe('delivery check synchronization', () => { + it('should check delivery status in syncTransfers using adapter', async () => { await transferStore.save({ id: '0xmsg1', status: 'in_progress', @@ -674,21 +673,19 @@ describe('ActionTracker', () => { }); explorerClient.getInflightUserTransfers.resolves([]); - mailboxStub.delivered.resolves(true); + mailboxStub.isDelivered.resolves(true); - const confirmedBlockTags = { chain2: 12345 }; - await tracker.syncTransfers(confirmedBlockTags); + await tracker.syncTransfers(); - expect(mailboxStub.delivered.calledOnce).to.be.true; - const call = mailboxStub.delivered.firstCall; + expect(mailboxStub.isDelivered.calledOnce).to.be.true; + const call = mailboxStub.isDelivered.firstCall; expect(call.args[0]).to.equal('0xmsg1'); - expect(call.args[1]).to.deep.equal({ blockTag: 12345 }); const transfer = await transferStore.get('0xmsg1'); expect(transfer?.status).to.equal('complete'); }); - it('should use provided blockTag in syncRebalanceActions delivery check', async () => { + it('should check delivery status in syncRebalanceActions using adapter', async () => { const intent: RebalanceIntent = { id: 'intent-1', status: 'in_progress', @@ -716,21 +713,65 @@ describe('ActionTracker', () => { await rebalanceActionStore.save(action); explorerClient.getInflightRebalanceActions.resolves([]); - mailboxStub.delivered.resolves(true); + mailboxStub.isDelivered.resolves(true); - const confirmedBlockTags = { chain2: 99999 }; - await tracker.syncRebalanceActions(confirmedBlockTags); + await tracker.syncRebalanceActions(); - expect(mailboxStub.delivered.calledOnce).to.be.true; - const call = mailboxStub.delivered.firstCall; + expect(mailboxStub.isDelivered.calledOnce).to.be.true; + const call = mailboxStub.isDelivered.firstCall; expect(call.args[0]).to.equal('0xmsg1'); - expect(call.args[1]).to.deep.equal({ blockTag: 99999 }); const updatedAction = await rebalanceActionStore.get('action-1'); expect(updatedAction?.status).to.equal('complete'); }); - it('should handle string blockTags (like "safe" or "finalized")', async () => { + it('should keep transfer in_progress when not delivered', async () => { + await transferStore.save({ + id: '0xmsg1', + status: 'in_progress', + messageId: '0xmsg1', + origin: 1, + destination: 2, + amount: 100n, + sender: '0xuser1', + recipient: '0xuser2', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + explorerClient.getInflightUserTransfers.resolves([]); + mailboxStub.isDelivered.resolves(false); + + await tracker.syncTransfers(); + + expect(mailboxStub.isDelivered.calledOnce).to.be.true; + const transfer = await transferStore.get('0xmsg1'); + expect(transfer?.status).to.equal('in_progress'); + }); + + it('should check delivery for multiple destinations', async () => { + await transferStore.save({ + id: '0xmsg1', + status: 'in_progress', + messageId: '0xmsg1', + origin: 1, + destination: 3, + amount: 100n, + sender: '0xuser1', + recipient: '0xuser2', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + explorerClient.getInflightUserTransfers.resolves([]); + mailboxStub.isDelivered.resolves(false); + + await tracker.syncTransfers(); + + expect(mailboxStub.isDelivered.calledOnce).to.be.true; + }); + + it('should pass blockTag when checking delivery in syncTransfers', async () => { await transferStore.save({ id: '0xmsg1', status: 'in_progress', @@ -745,25 +786,65 @@ describe('ActionTracker', () => { }); explorerClient.getInflightUserTransfers.resolves([]); - mailboxStub.delivered.resolves(false); + mailboxStub.isDelivered.resolves(true); - const confirmedBlockTags: ConfirmedBlockTags = { + const confirmedBlockTags = { chain2: EthJsonRpcBlockParameterTag.Finalized, }; await tracker.syncTransfers(confirmedBlockTags); - expect(mailboxStub.delivered.calledOnce).to.be.true; - const call = mailboxStub.delivered.firstCall; - expect(call.args[1]).to.deep.equal({ blockTag: 'finalized' }); + expect(mailboxStub.isDelivered.calledOnce).to.be.true; + const call = mailboxStub.isDelivered.firstCall; + expect(call.args[0]).to.equal('0xmsg1'); + expect(call.args[1]).to.equal(EthJsonRpcBlockParameterTag.Finalized); + }); + + it('should pass blockTag when checking delivery in syncRebalanceActions', async () => { + const intent: RebalanceIntent = { + id: 'intent-1', + status: 'in_progress', + origin: 1, + destination: 2, + amount: 100n, + fulfilledAmount: 0n, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const action: RebalanceAction = { + id: 'action-1', + status: 'in_progress', + intentId: 'intent-1', + messageId: '0xmsg1', + origin: 1, + destination: 2, + amount: 100n, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + await rebalanceIntentStore.save(intent); + await rebalanceActionStore.save(action); + + explorerClient.getInflightRebalanceActions.resolves([]); + mailboxStub.isDelivered.resolves(true); + + const confirmedBlockTags = { chain2: 12345 }; + await tracker.syncRebalanceActions(confirmedBlockTags); + + expect(mailboxStub.isDelivered.calledOnce).to.be.true; + const call = mailboxStub.isDelivered.firstCall; + expect(call.args[0]).to.equal('0xmsg1'); + expect(call.args[1]).to.equal(12345); }); - it('should handle undefined blockTag for chain not in confirmedBlockTags', async () => { + it('should pass undefined blockTag when confirmedBlockTags not provided', async () => { await transferStore.save({ id: '0xmsg1', status: 'in_progress', messageId: '0xmsg1', origin: 1, - destination: 3, + destination: 2, amount: 100n, sender: '0xuser1', recipient: '0xuser2', @@ -772,12 +853,14 @@ describe('ActionTracker', () => { }); explorerClient.getInflightUserTransfers.resolves([]); - mailboxStub.delivered.resolves(false); + mailboxStub.isDelivered.resolves(false); - const confirmedBlockTags = { chain2: 12345 }; - await tracker.syncTransfers(confirmedBlockTags); + await tracker.syncTransfers(); // No confirmedBlockTags - expect(mailboxStub.delivered.calledOnce).to.be.true; + expect(mailboxStub.isDelivered.calledOnce).to.be.true; + const call = mailboxStub.isDelivered.firstCall; + expect(call.args[0]).to.equal('0xmsg1'); + expect(call.args[1]).to.be.undefined; }); }); }); diff --git a/typescript/rebalancer/src/tracking/ActionTracker.ts b/typescript/rebalancer/src/tracking/ActionTracker.ts index d73738f6670..8b7cda2e820 100644 --- a/typescript/rebalancer/src/tracking/ActionTracker.ts +++ b/typescript/rebalancer/src/tracking/ActionTracker.ts @@ -1,18 +1,16 @@ import type { Logger } from 'pino'; import { v4 as uuidv4 } from 'uuid'; -import type { HyperlaneCore } from '@hyperlane-xyz/sdk'; +import type { MultiProtocolCore } from '@hyperlane-xyz/sdk'; import type { Address, Domain } from '@hyperlane-xyz/utils'; import { parseWarpRouteMessage } from '@hyperlane-xyz/utils'; -import type { - ConfirmedBlockTag, - ConfirmedBlockTags, -} from '../interfaces/IMonitor.js'; +import type { ConfirmedBlockTags } from '../interfaces/IMonitor.js'; import type { ExplorerClient, ExplorerMessage, } from '../utils/ExplorerClient.js'; +import { getConfirmedBlockTag } from '../utils/blockTag.js'; import type { CreateRebalanceActionParams, @@ -43,7 +41,7 @@ export class ActionTracker implements IActionTracker { private readonly rebalanceIntentStore: IRebalanceIntentStore, private readonly rebalanceActionStore: IRebalanceActionStore, private readonly explorerClient: ExplorerClient, - private readonly core: HyperlaneCore, + private readonly core: MultiProtocolCore, private readonly config: ActionTrackerConfig, private readonly logger: Logger, ) {} @@ -171,11 +169,10 @@ export class ActionTracker implements IActionTracker { const existingTransfers = await this.getInProgressTransfers(); for (const transfer of existingTransfers) { - const chainName = this.core.multiProvider.getChainName( + const blockTag = await this.getConfirmedBlockTag( transfer.destination, + confirmedBlockTags, ); - const blockTag = confirmedBlockTags?.[chainName]; - const delivered = await this.isMessageDelivered( transfer.messageId, transfer.destination, @@ -263,11 +260,10 @@ export class ActionTracker implements IActionTracker { const inProgressActions = await this.rebalanceActionStore.getByStatus('in_progress'); for (const action of inProgressActions) { - const chainName = this.core.multiProvider.getChainName( + const blockTag = await this.getConfirmedBlockTag( action.destination, + confirmedBlockTags, ); - const blockTag = confirmedBlockTags?.[chainName]; - const delivered = await this.isMessageDelivered( action.messageId, action.destination, @@ -515,45 +511,43 @@ export class ActionTracker implements IActionTracker { // === Private Helpers === + /** + * Get the confirmed block tag for delivery checks. + * Uses cached value from Monitor event if available, otherwise computes on-demand. + */ private async getConfirmedBlockTag( - chainName: string, - ): Promise { - try { - const metadata = this.core.multiProvider.getChainMetadata(chainName); - const reorgPeriod = metadata.blocks?.reorgPeriod ?? 32; - - if (typeof reorgPeriod === 'string') { - return reorgPeriod as ConfirmedBlockTag; - } + destination: Domain, + confirmedBlockTags?: ConfirmedBlockTags, + ): Promise { + const chainName = this.core.multiProvider.getChainName(destination); - const provider = this.core.multiProvider.getProvider(chainName); - const latestBlock = await provider.getBlockNumber(); - return Math.max(0, latestBlock - reorgPeriod); - } catch (error) { - this.logger.warn( - { chain: chainName, error: (error as Error).message }, - 'Failed to get confirmed block, using latest', - ); - return undefined; + // If tags provided (from Monitor event), use cached value + if (confirmedBlockTags) { + return confirmedBlockTags[chainName]; } + + // Otherwise compute on-demand (e.g., during initialize()) + return getConfirmedBlockTag( + this.core.multiProvider, + chainName, + this.logger, + ); } private async isMessageDelivered( messageId: string, destination: Domain, - providedBlockTag?: ConfirmedBlockTag, + blockTag?: string | number, ): Promise { try { const chainName = this.core.multiProvider.getChainName(destination); - const mailbox = this.core.getContracts(chainName).mailbox; - - const blockTag = - providedBlockTag ?? (await this.getConfirmedBlockTag(chainName)); - const delivered = await mailbox.delivered(messageId, { blockTag }); + const delivered = await this.core + .adapter(chainName) + .isDelivered(messageId, blockTag); this.logger.debug( - { messageId, destination: chainName, blockTag, delivered }, - 'Checked message delivery at confirmed block', + { messageId, destination: chainName, delivered, blockTag }, + 'Checked message delivery', ); return delivered; diff --git a/typescript/rebalancer/src/utils/blockTag.ts b/typescript/rebalancer/src/utils/blockTag.ts new file mode 100644 index 00000000000..aa41a85e79d --- /dev/null +++ b/typescript/rebalancer/src/utils/blockTag.ts @@ -0,0 +1,42 @@ +import type { Logger } from 'pino'; + +import { + EthJsonRpcBlockParameterTag, + MultiProtocolProvider, +} from '@hyperlane-xyz/sdk'; + +import type { ConfirmedBlockTag } from '../interfaces/IMonitor.js'; + +/** + * Get the confirmed block tag for a chain, accounting for reorg period. + * Returns a block number that is safe from reorgs, or a named tag like 'finalized'. + * + * @param multiProvider - MultiProtocolProvider instance + * @param chainName - Name of the chain + * @param logger - Optional logger for warnings + * @returns Confirmed block tag (number, named tag, or undefined on error) + */ +export async function getConfirmedBlockTag( + multiProvider: MultiProtocolProvider, + chainName: string, + logger?: Logger, +): Promise { + try { + const metadata = multiProvider.getChainMetadata(chainName); + const reorgPeriod = metadata.blocks?.reorgPeriod ?? 32; + + if (typeof reorgPeriod === 'string') { + return reorgPeriod as EthJsonRpcBlockParameterTag; + } + + const provider = multiProvider.getEthersV5Provider(chainName); + const latestBlock = await provider.getBlockNumber(); + return Math.max(0, latestBlock - reorgPeriod); + } catch (error) { + logger?.warn( + { chain: chainName, error: (error as Error).message }, + 'Failed to get confirmed block, using latest', + ); + return undefined; + } +} diff --git a/typescript/rebalancer/src/utils/index.ts b/typescript/rebalancer/src/utils/index.ts index 5170c22379e..1c20964936a 100644 --- a/typescript/rebalancer/src/utils/index.ts +++ b/typescript/rebalancer/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './balanceUtils.js'; +export * from './blockTag.js'; export * from './bridgeUtils.js'; export * from './tokenUtils.js';