diff --git a/solidity/contracts/mock/MockValueTransferBridge.sol b/solidity/contracts/mock/MockValueTransferBridge.sol index cdc779a1a5e..ee4998acc42 100644 --- a/solidity/contracts/mock/MockValueTransferBridge.sol +++ b/solidity/contracts/mock/MockValueTransferBridge.sol @@ -2,16 +2,14 @@ pragma solidity ^0.8.13; import {ITokenBridge, Quote} from "../interfaces/ITokenBridge.sol"; +import {Router} from "../client/Router.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ERC20Test} from "../test/ERC20Test.sol"; -contract MockValueTransferBridge is ITokenBridge { +contract MockValueTransferBridge is Router, ITokenBridge { using SafeERC20 for IERC20; - address public collateral; - - constructor(address _collateral) { - collateral = _collateral; - } + address public immutable collateral; event SentTransferRemote( uint32 indexed origin, @@ -20,14 +18,31 @@ contract MockValueTransferBridge is ITokenBridge { uint256 amount ); + constructor(address _collateral, address _mailbox) Router(_mailbox) { + collateral = _collateral; + } + + function initialize( + address _hook, + address _ism, + address _owner + ) external initializer { + _MailboxClient_initialize(_hook, _ism, _owner); + } + function quoteTransferRemote( - uint32, //_destinationDomain, - bytes32, //_recipient, - uint256 //_amountOut + uint32 _destinationDomain, + bytes32 _recipient, + uint256 _amountOut ) public view virtual override returns (Quote[] memory) { - Quote[] memory quotes = new Quote[](1); - quotes[0] = Quote(collateral, 1); + uint256 dispatchFee = _Router_quoteDispatch( + _destinationDomain, + abi.encode(_recipient, _amountOut) + ); + Quote[] memory quotes = new Quote[](2); + quotes[0] = Quote(collateral, 1); + quotes[1] = Quote(address(0), dispatchFee); return quotes; } @@ -50,6 +65,25 @@ contract MockValueTransferBridge is ITokenBridge { _amountOut ); - return keccak256("transferId"); + // Dispatch through MockMailbox so Dispatch event is emitted + transferId = _Router_dispatch( + _destinationDomain, + msg.value, + abi.encode(_recipient, _amountOut) + ); + } + + function _handle( + uint32, // _origin + bytes32, // _sender + bytes calldata _message + ) internal virtual override { + (bytes32 recipientBytes32, uint256 amount) = abi.decode( + _message, + (bytes32, uint256) + ); + address recipient = address(uint160(uint256(recipientBytes32))); + // Mint collateral tokens to recipient (destination warp token) + ERC20Test(collateral).mintTo(recipient, amount); } } diff --git a/typescript/cli/src/tests/ethereum/warp/warp-rebalancer.e2e-test.ts b/typescript/cli/src/tests/ethereum/warp/warp-rebalancer.e2e-test.ts index 7f4fe79e65e..0586a4980fb 100644 --- a/typescript/cli/src/tests/ethereum/warp/warp-rebalancer.e2e-test.ts +++ b/typescript/cli/src/tests/ethereum/warp/warp-rebalancer.e2e-test.ts @@ -823,7 +823,12 @@ describe('hyperlane warp rebalancer e2e tests', async function () { // Deploy the bridge const bridgeContract = await new MockValueTransferBridge__factory( chain3Signer, - ).deploy(tokenChain3.address); + ).deploy(tokenChain3.address, chain3Addresses.mailbox); + await bridgeContract.initialize( + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ANVIL_DEPLOYER_ADDRESS, + ); // Allow bridge await chain3CollateralContract.addBridge( @@ -877,7 +882,12 @@ describe('hyperlane warp rebalancer e2e tests', async function () { // Deploy the bridge const bridgeContract = await new MockValueTransferBridge__factory( chain3Signer, - ).deploy(tokenChain3.address); + ).deploy(tokenChain3.address, chain3Addresses.mailbox); + await bridgeContract.initialize( + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ANVIL_DEPLOYER_ADDRESS, + ); // Allow bridge await chain3CollateralContract.addBridge( @@ -961,7 +971,16 @@ describe('hyperlane warp rebalancer e2e tests', async function () { // It will also allow us to mock some token movement const bridgeContract = await new MockValueTransferBridge__factory( originSigner, - ).deploy(tokenChain3.address); + ).deploy(tokenChain3.address, chain3Addresses.mailbox); + await bridgeContract.initialize( + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ANVIL_DEPLOYER_ADDRESS, + ); + await bridgeContract.enrollRemoteRouter( + destDomain, + addressToBytes32(destContractAddress), + ); // Allow bridge // This allow the bridge to be used to send the rebalance transaction @@ -1208,7 +1227,16 @@ describe('hyperlane warp rebalancer e2e tests', async function () { // It will also allow us to mock some token movement const bridgeContract = await new MockValueTransferBridge__factory( originSigner, - ).deploy(tokenChain3.address); + ).deploy(tokenChain3.address, chain3Addresses.mailbox); + await bridgeContract.initialize( + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ANVIL_DEPLOYER_ADDRESS, + ); + await bridgeContract.enrollRemoteRouter( + destDomain, + addressToBytes32(destContractAddress), + ); // Allow bridge // This allow the bridge to be used to send the rebalance transaction diff --git a/typescript/rebalancer-sim/scenarios/inflight-guard.json b/typescript/rebalancer-sim/scenarios/inflight-guard.json index 8d106313a17..bcbcc030c79 100644 --- a/typescript/rebalancer-sim/scenarios/inflight-guard.json +++ b/typescript/rebalancer-sim/scenarios/inflight-guard.json @@ -1,8 +1,8 @@ { "name": "inflight-guard", "description": "Tests rebalancer behavior with slow bridge and fast polling to demonstrate inflight guard importance.", - "expectedBehavior": "With SLOW bridge (3s) and polling at 1000ms, a rebalancer without inflight tracking will over-rebalance.\nWithout inflight guard: Multiple redundant transfers sent before first one delivers.\nWith inflight guard: Only 1-2 transfers sent, tracking pending amounts.", - "duration": 8000, + "expectedBehavior": "With SLOW bridge (4s) and polling at 1000ms, a rebalancer without inflight tracking will over-rebalance.\nWithout inflight guard: Multiple redundant transfers sent before first one delivers.\nWith inflight guard: Only 1-2 transfers sent, tracking pending amounts.", + "duration": 10000, "chains": ["chain1", "chain2"], "initialImbalance": { "chain1": "50000000000000000000" @@ -18,27 +18,11 @@ }, { "id": "keepalive-2", - "timestamp": 3000, - "origin": "chain1", - "destination": "chain2", - "amount": "40000000000000000000", - "user": "0x1111111111111111111111111111111111111111" - }, - { - "id": "keepalive-3", "timestamp": 5000, "origin": "chain1", "destination": "chain2", "amount": "40000000000000000000", "user": "0x1111111111111111111111111111111111111111" - }, - { - "id": "keepalive-4", - "timestamp": 7000, - "origin": "chain1", - "destination": "chain2", - "amount": "40000000000000000000", - "user": "0x1111111111111111111111111111111111111111" } ], "defaultInitialCollateral": "100000000000000000000", @@ -50,14 +34,14 @@ "defaultBridgeConfig": { "chain1": { "chain2": { - "deliveryDelay": 3000, + "deliveryDelay": 4000, "failureRate": 0, "deliveryJitter": 0 } }, "chain2": { "chain1": { - "deliveryDelay": 3000, + "deliveryDelay": 4000, "failureRate": 0, "deliveryJitter": 0 } diff --git a/typescript/rebalancer-sim/src/BridgeMockController.ts b/typescript/rebalancer-sim/src/BridgeMockController.ts deleted file mode 100644 index 149794c3062..00000000000 --- a/typescript/rebalancer-sim/src/BridgeMockController.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { ethers } from 'ethers'; -import { EventEmitter } from 'events'; - -import { - ERC20Test__factory, - MockValueTransferBridge__factory, -} from '@hyperlane-xyz/core'; -import type { Address } from '@hyperlane-xyz/utils'; -import { rootLogger } from '@hyperlane-xyz/utils'; - -import type { - BridgeEvent, - BridgeMockConfig, - BridgeRouteConfig, - DeployedDomain, - PendingTransfer, -} from './types.js'; -import { DEFAULT_BRIDGE_ROUTE_CONFIG } from './types.js'; - -const logger = rootLogger.child({ module: 'BridgeMockController' }); - -/** - * BridgeMockController manages simulated bridge transfers with configurable - * delays, failures, and fees. It intercepts SentTransferRemote events and - * schedules async delivery to simulate real bridge behavior. - */ -export class BridgeMockController extends EventEmitter { - private pendingTransfers: Map = new Map(); - private completedTransfers: PendingTransfer[] = []; - private transferCounter = 0; - private deliveryTimers: Map = new Map(); - private isRunning = false; - private eventListeners: Map = new Map(); - - // Transaction queue to prevent nonce collisions - private txQueue: Array<() => Promise> = []; - private txProcessing = false; - - constructor( - private readonly provider: ethers.providers.JsonRpcProvider, - private readonly domains: Record, - private readonly deployerKey: string, - private readonly bridgeConfig: BridgeMockConfig = {}, - ) { - super(); - } - - /** - * Queue a transaction to be executed serially (prevents nonce collisions) - */ - private async queueTransaction(fn: () => Promise): Promise { - return new Promise((resolve, reject) => { - this.txQueue.push(async () => { - try { - await fn(); - resolve(); - } catch (error) { - reject(error); - } - }); - void this.processQueue(); - }); - } - - /** - * Process queued transactions one at a time - */ - private async processQueue(): Promise { - if (this.txProcessing || this.txQueue.length === 0) return; - - this.txProcessing = true; - while (this.txQueue.length > 0) { - const fn = this.txQueue.shift(); - if (fn) { - try { - await fn(); - } catch (_error) { - // Error already handled in queueTransaction - } - } - } - this.txProcessing = false; - } - - /** - * Gets the bridge config for a specific route - */ - private getRouteConfig( - origin: string, - destination: string, - ): BridgeRouteConfig { - return ( - this.bridgeConfig[origin]?.[destination] ?? DEFAULT_BRIDGE_ROUTE_CONFIG - ); - } - - /** - * Calculates delivery delay with jitter - */ - private calculateDelay(config: BridgeRouteConfig): number { - const jitter = (Math.random() - 0.5) * 2 * config.deliveryJitter; - return Math.max(0, config.deliveryDelay + jitter); - } - - /** - * Start listening for bridge events - */ - async start(): Promise { - if (this.isRunning) return; - this.isRunning = true; - - const deployer = new ethers.Wallet(this.deployerKey, this.provider); - - // Set up event listeners for each bridge - for (const [chainName, domain] of Object.entries(this.domains)) { - const bridge = MockValueTransferBridge__factory.connect( - domain.bridge, - deployer, - ); - - // Listen for SentTransferRemote events - bridge.on( - bridge.filters.SentTransferRemote(), - (origin, destination, recipient, amount) => { - void this.onTransferInitiated( - chainName, - origin, - destination, - recipient, - amount.toBigInt(), - ); - }, - ); - - this.eventListeners.set(chainName, bridge); - } - } - - /** - * Stop listening and cancel pending deliveries - */ - async stop(): Promise { - this.isRunning = false; - - // Remove event listeners - for (const bridge of this.eventListeners.values()) { - bridge.removeAllListeners(); - } - this.eventListeners.clear(); - - // Cancel pending delivery timers - for (const timer of this.deliveryTimers.values()) { - clearTimeout(timer); - } - this.deliveryTimers.clear(); - } - - /** - * Handle transfer initiated event - */ - private async onTransferInitiated( - originChain: string, - originDomainId: number, - destinationDomainId: number, - recipientBytes32: string, - amount: bigint, - ): Promise { - // Find destination chain by domain ID - const destChain = Object.entries(this.domains).find( - ([_, d]) => d.domainId === destinationDomainId, - )?.[0]; - - if (!destChain) { - logger.error({ destinationDomainId }, 'Unknown destination domain'); - return; - } - - const config = this.getRouteConfig(originChain, destChain); - const transferId = `bridge-${this.transferCounter++}`; - const delay = this.calculateDelay(config); - - // Apply token fee if configured - let netAmount = amount; - if (config.tokenFeeBps) { - const fee = (amount * BigInt(config.tokenFeeBps)) / BigInt(10000); - netAmount = amount - fee; - } - - const recipient = ethers.utils.hexDataSlice( - recipientBytes32, - 12, - ) as Address; - - // MockValueTransferBridge pulls tokens from origin warp token. - // Bridge delivery mints to destination, preserving total warp token collateral. - - const pendingTransfer: PendingTransfer = { - id: transferId, - origin: originChain, - destination: destChain, - amount: netAmount, - recipient, - scheduledDelivery: Date.now() + delay, - failed: false, - delivered: false, - }; - - this.pendingTransfers.set(transferId, pendingTransfer); - - // Emit event - const bridgeEvent: BridgeEvent = { - type: 'transfer_initiated', - transfer: pendingTransfer, - timestamp: Date.now(), - }; - this.emit('transfer_initiated', bridgeEvent); - - // Schedule delivery - const timer = setTimeout( - () => this.executeDelivery(transferId, config), - delay, - ); - this.deliveryTimers.set(transferId, timer); - } - - /** - * Execute delivery of a pending transfer - */ - private async executeDelivery( - transferId: string, - config: BridgeRouteConfig, - ): Promise { - const transfer = this.pendingTransfers.get(transferId); - if (!transfer || transfer.delivered) return; - - this.deliveryTimers.delete(transferId); - - // Check for failure - if (Math.random() < config.failureRate) { - transfer.failed = true; - this.pendingTransfers.delete(transferId); - this.completedTransfers.push(transfer); - - const event: BridgeEvent = { - type: 'transfer_failed', - transfer, - timestamp: Date.now(), - }; - this.emit('transfer_failed', event); - return; - } - - try { - // Execute the delivery by simulating tokens arriving at destination - // In a real scenario, this would call the destination warp token's handle function - // For simulation, we directly transfer tokens to simulate bridge completion - await this.simulateBridgeDelivery(transfer); - - transfer.delivered = true; - transfer.deliveredAt = Date.now(); - this.pendingTransfers.delete(transferId); - this.completedTransfers.push(transfer); - - const event: BridgeEvent = { - type: 'transfer_delivered', - transfer, - timestamp: Date.now(), - }; - this.emit('transfer_delivered', event); - } catch (error) { - logger.error({ transferId, error }, 'Bridge delivery failed'); - transfer.failed = true; - this.pendingTransfers.delete(transferId); - this.completedTransfers.push(transfer); - - const event: BridgeEvent = { - type: 'transfer_failed', - transfer, - timestamp: Date.now(), - }; - this.emit('transfer_failed', event); - } - } - - /** - * Simulate bridge delivery by burning from origin bridge and minting to destination. - * This maintains token conservation across the simulation. - * Uses transaction queue to prevent nonce collisions. - */ - private async simulateBridgeDelivery( - transfer: PendingTransfer, - ): Promise { - await this.queueTransaction(async () => { - const deployer = new ethers.Wallet(this.deployerKey, this.provider); - const originDomain = this.domains[transfer.origin]; - const destDomain = this.domains[transfer.destination]; - - // Burn from origin bridge (tokens are guaranteed to be there since SentTransferRemote fired) - const originCollateralToken = ERC20Test__factory.connect( - originDomain.collateralToken, - deployer, - ); - const burnTx = await originCollateralToken.burnFrom( - originDomain.bridge, - transfer.amount.toString(), - ); - await burnTx.wait(); - - // Mint same amount to destination warp token - const destCollateralToken = ERC20Test__factory.connect( - destDomain.collateralToken, - deployer, - ); - const mintTx = await destCollateralToken.mintTo( - destDomain.warpToken, - transfer.amount.toString(), - ); - await mintTx.wait(); - }); - } - - /** - * Manually trigger delivery for a pending transfer (for testing) - */ - async forceDelivery(transferId: string): Promise { - const transfer = this.pendingTransfers.get(transferId); - if (!transfer) { - throw new Error(`Transfer not found: ${transferId}`); - } - - // Cancel scheduled delivery - const timer = this.deliveryTimers.get(transferId); - if (timer) { - clearTimeout(timer); - this.deliveryTimers.delete(transferId); - } - - // Execute immediately - await this.executeDelivery( - transferId, - this.getRouteConfig(transfer.origin, transfer.destination), - ); - } - - /** - * Check if there are pending transfers - */ - hasPendingTransfers(): boolean { - return this.pendingTransfers.size > 0; - } - - /** - * Get count of pending transfers - */ - getPendingCount(): number { - return this.pendingTransfers.size; - } - - /** - * Get all pending transfers - */ - getPendingTransfers(): PendingTransfer[] { - return Array.from(this.pendingTransfers.values()); - } - - /** - * Get completed transfers - */ - getCompletedTransfers(): PendingTransfer[] { - return [...this.completedTransfers]; - } - - /** - * Wait for all pending transfers to complete - * On timeout, marks remaining transfers as failed and clears them - */ - async waitForAllDeliveries(timeoutMs: number = 30000): Promise { - const startTime = Date.now(); - - while (this.hasPendingTransfers()) { - if (Date.now() - startTime > timeoutMs) { - const pendingCount = this.getPendingCount(); - logger.warn( - { pendingCount }, - 'Timeout waiting for bridge deliveries - marking as failed', - ); - // Mark all pending as failed, update state, and clear - for (const transfer of this.pendingTransfers.values()) { - transfer.failed = true; - this.completedTransfers.push(transfer); - const event: BridgeEvent = { - type: 'transfer_failed', - transfer, - timestamp: Date.now(), - }; - this.emit('transfer_failed', event); - } - this.pendingTransfers.clear(); - break; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } -} diff --git a/typescript/rebalancer-sim/src/MessageTracker.ts b/typescript/rebalancer-sim/src/MessageTracker.ts deleted file mode 100644 index 87ff3f066d5..00000000000 --- a/typescript/rebalancer-sim/src/MessageTracker.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { ethers } from 'ethers'; -import { EventEmitter } from 'events'; - -import { MockMailbox__factory } from '@hyperlane-xyz/core'; -import { rootLogger } from '@hyperlane-xyz/utils'; - -import type { DeployedDomain } from './types.js'; - -const logger = rootLogger.child({ module: 'MessageTracker' }); - -/** - * Tracked message for off-chain processing control - */ -export interface TrackedMessage { - id: string; - transferId: string; - origin: string; - destination: string; - /** Nonce on the destination mailbox */ - destinationNonce: number; - /** When the message was dispatched */ - dispatchedAt: number; - /** When we should attempt delivery */ - deliveryTime: number; - /** Processing status */ - status: 'pending' | 'inflight' | 'delivered' | 'failed'; - /** Number of delivery attempts */ - attempts: number; - /** Last error if failed */ - lastError?: string; -} - -/** - * MessageTracker provides off-chain tracking and selective processing - * of Hyperlane messages. Fires transactions in parallel without blocking - * on receipts, similar to how the Hyperlane relayer batches messages. - */ -export class MessageTracker extends EventEmitter { - private messages: Map = new Map(); - private messageCounter = 0; - private destinationNonces: Map = new Map(); - private signer: ethers.Wallet; - private currentNonce: number = 0; - private nonceInitialized = false; - - constructor( - private readonly provider: ethers.providers.JsonRpcProvider, - private readonly domains: Record, - signerKey: string, - ) { - super(); - this.signer = new ethers.Wallet(signerKey, provider); - } - - /** - * Initialize by fetching current nonces from all destination mailboxes - */ - async initialize(): Promise { - for (const [chainName, domain] of Object.entries(this.domains)) { - const mailbox = MockMailbox__factory.connect( - domain.mailbox, - this.provider, - ); - const nonce = await mailbox.inboundUnprocessedNonce(); - this.destinationNonces.set(chainName, Number(nonce)); - } - // Initialize signer nonce for parallel tx submission - this.currentNonce = await this.signer.getTransactionCount(); - this.nonceInitialized = true; - } - - /** - * Track a new message after a transfer is initiated. - * Call this after transferRemote() succeeds. - */ - async trackMessage( - transferId: string, - origin: string, - destination: string, - deliveryDelay: number, - ): Promise { - const destDomain = this.domains[destination]; - const mailbox = MockMailbox__factory.connect( - destDomain.mailbox, - this.provider, - ); - await mailbox.inboundUnprocessedNonce(); // Verify mailbox is accessible - - const expectedNonce = this.destinationNonces.get(destination) || 0; - this.destinationNonces.set(destination, expectedNonce + 1); - - const message: TrackedMessage = { - id: `msg-${this.messageCounter++}`, - transferId, - origin, - destination, - destinationNonce: expectedNonce, - dispatchedAt: Date.now(), - deliveryTime: Date.now() + deliveryDelay, - status: 'pending', - attempts: 0, - }; - - this.messages.set(message.id, message); - this.emit('message_tracked', message); - - return message; - } - - /** - * Get all messages ready for delivery (past their delivery time, not inflight) - */ - getReadyMessages(): TrackedMessage[] { - const now = Date.now(); - return Array.from(this.messages.values()).filter( - (m) => m.status === 'pending' && m.deliveryTime <= now, - ); - } - - /** - * Get all pending messages (including not yet ready and inflight) - */ - getPendingMessages(): TrackedMessage[] { - return Array.from(this.messages.values()).filter( - (m) => m.status === 'pending' || m.status === 'inflight', - ); - } - - /** - * Process all ready messages in parallel without blocking on receipts. - * Fires transactions and subscribes to completion asynchronously. - */ - async processReadyMessages(): Promise<{ delivered: number; failed: number }> { - const ready = this.getReadyMessages(); - if (ready.length === 0) { - return { delivered: 0, failed: 0 }; - } - - // Ensure nonce is initialized - if (!this.nonceInitialized) { - this.currentNonce = await this.signer.getTransactionCount(); - this.nonceInitialized = true; - } - - // Check which messages can actually be processed (have sufficient liquidity) - // by doing a static call first - const processable: TrackedMessage[] = []; - - const checkStartTime = Date.now(); - for (const message of ready) { - const destDomain = this.domains[message.destination]; - const mailbox = MockMailbox__factory.connect( - destDomain.mailbox, - this.signer, - ); - - const staticCallStart = Date.now(); - try { - // Static call to check if it would succeed - await mailbox.callStatic.processInboundMessage( - message.destinationNonce, - ); - const staticCallDuration = Date.now() - staticCallStart; - if (staticCallDuration > 100) { - logger.warn( - { transferId: message.transferId, staticCallDuration }, - 'Slow static call', - ); - } - processable.push(message); - // Log successful processing after retries - if (message.attempts > 0) { - const waitTime = Date.now() - message.dispatchedAt; - logger.debug( - { - transferId: message.transferId, - origin: message.origin, - destination: message.destination, - attempts: message.attempts, - waitTime, - }, - 'Message ready after retries', - ); - } - } catch (error: any) { - const staticCallDuration = Date.now() - staticCallStart; - const errorMsg = error.reason || error.message || ''; - // Check if message was already delivered (e.g., by bridge controller) - // This is a permanent state, not a temporary error - if (errorMsg.includes('already delivered')) { - message.status = 'delivered'; - this.emit('message_delivered', message); - continue; - } - // Other errors - mark attempt but keep pending for retry - message.attempts++; - message.lastError = errorMsg; - - // Log failures - every 5 attempts or on slow static calls - if (message.attempts % 5 === 0 || staticCallDuration > 100) { - const waitTime = Date.now() - message.dispatchedAt; - logger.debug( - { - transferId: message.transferId, - origin: message.origin, - destination: message.destination, - attempts: message.attempts, - waitTime, - error: errorMsg, - }, - 'Message delivery failed, will retry', - ); - } - } - } - - const totalCheckTime = Date.now() - checkStartTime; - if (totalCheckTime > 500) { - logger.warn( - { messageCount: ready.length, totalCheckTime }, - 'Slow static call checks', - ); - } - - if (processable.length === 0) { - // No messages processable yet - not a failure, they will retry - return { delivered: 0, failed: 0 }; - } - - // Fire all processable transactions in parallel - const txPromises: Array<{ - message: TrackedMessage; - txPromise: Promise; - }> = []; - - for (const message of processable) { - message.status = 'inflight'; - message.attempts++; - - const destDomain = this.domains[message.destination]; - const mailbox = MockMailbox__factory.connect( - destDomain.mailbox, - this.signer, - ); - - // Fire transaction with explicit nonce (don't wait) - const txPromise = mailbox.processInboundMessage( - message.destinationNonce, - { nonce: this.currentNonce++ }, - ); - - txPromises.push({ message, txPromise }); - } - - // Subscribe to all tx completions asynchronously - let delivered = 0; - let failed = 0; - - await Promise.all( - txPromises.map(async ({ message, txPromise }) => { - try { - const tx = await txPromise; - await tx.wait(); - - message.status = 'delivered'; - this.emit('message_delivered', message); - delivered++; - } catch (error: any) { - // Transaction failed - back to pending for retry - message.status = 'pending'; - message.lastError = error.reason || error.message; - failed++; - } - }), - ); - - return { delivered, failed }; - } - - /** - * Check if there are any pending or inflight messages - */ - hasPendingMessages(): boolean { - return this.getPendingMessages().length > 0; - } - - /** - * Get message by transfer ID - */ - getMessageByTransferId(transferId: string): TrackedMessage | undefined { - return Array.from(this.messages.values()).find( - (m) => m.transferId === transferId, - ); - } - - /** - * Get all messages - */ - getAllMessages(): TrackedMessage[] { - return Array.from(this.messages.values()); - } - - /** - * Clear all tracked messages (for reset) - */ - clear(): void { - this.messages.clear(); - this.messageCounter = 0; - this.destinationNonces.clear(); - this.nonceInitialized = false; - } -} diff --git a/typescript/rebalancer-sim/src/MockInfrastructureController.ts b/typescript/rebalancer-sim/src/MockInfrastructureController.ts new file mode 100644 index 00000000000..541d8ad3101 --- /dev/null +++ b/typescript/rebalancer-sim/src/MockInfrastructureController.ts @@ -0,0 +1,346 @@ +import { ethers } from 'ethers'; + +import type { HyperlaneCore } from '@hyperlane-xyz/sdk'; +import { rootLogger } from '@hyperlane-xyz/utils'; + +import { KPICollector } from './KPICollector.js'; +import { MockActionTracker } from './runners/MockActionTracker.js'; +import type { + BridgeMockConfig, + BridgeRouteConfig, + DeployedDomain, +} from './types.js'; +import { DEFAULT_BRIDGE_ROUTE_CONFIG } from './types.js'; + +const logger = rootLogger.child({ module: 'MockInfrastructureController' }); + +/** Hyperlane message body starts at byte offset 77 (version:1 + nonce:4 + origin:4 + sender:32 + dest:4 + recipient:32) */ +const MESSAGE_BODY_OFFSET = 77; +/** Warp tokens scale amounts by 10^decimals; simulation uses 18 decimals */ +const WARP_TOKEN_SCALE = BigInt(1e18); + +/** Pending message awaiting delayed delivery */ +interface PendingMessage { + /** keccak256(message) — real Hyperlane messageId */ + messageId: string; + /** Full message bytes hex */ + message: string; + destination: string; + deliveryTime: number; + type: 'user-transfer' | 'bridge-transfer'; + /** Origin chain name */ + origin: string; + /** Decoded amount from body */ + amount: bigint; + /** Number of delivery attempts */ + attempts: number; +} + +/** + * MockInfrastructureController listens for Dispatch events on all Mailboxes, + * classifies messages by sender (warp vs bridge), and delivers them with + * configurable delays by calling process('0x', message) on the destination mailbox. + * + * Auto-tracks both user transfers and bridge transfers from Dispatch events — + * no external registration needed. + */ +export class MockInfrastructureController { + private pendingMessages: PendingMessage[] = []; + private isRunning = false; + private processLoopPromise?: Promise; + + constructor( + private readonly core: HyperlaneCore, + private readonly domains: Record, + private readonly bridgeDelayConfig: BridgeMockConfig, + private readonly userTransferDelay: number, + private readonly kpiCollector: KPICollector, + private readonly actionTracker?: MockActionTracker, + ) {} + + private getRouteConfig( + origin: string, + destination: string, + ): BridgeRouteConfig { + return ( + this.bridgeDelayConfig[origin]?.[destination] ?? + DEFAULT_BRIDGE_ROUTE_CONFIG + ); + } + + private calculateBridgeDelay(config: BridgeRouteConfig): number { + const jitter = (Math.random() - 0.5) * 2 * config.deliveryJitter; + return Math.max(0, config.deliveryDelay + jitter); + } + + /** + * Start listening for Dispatch events and processing messages + */ + async start(): Promise { + if (this.isRunning) return; + this.isRunning = true; + + // Listen for Dispatch events on all mailboxes + for (const chainName of this.core.multiProvider.getKnownChainNames()) { + const mailbox = this.core.getContracts(chainName).mailbox; + mailbox.on( + mailbox.filters.Dispatch(), + ( + sender: string, + destination: number, + _recipient: string, + message: string, + ) => { + this.onDispatch(chainName, sender, destination, message).catch( + (error: unknown) => { + logger.error( + { origin: chainName, error }, + 'Unhandled error in onDispatch', + ); + }, + ); + }, + ); + } + + // Start processing loop + this.processLoopPromise = this.processLoop(); + } + + /** + * Handle a Dispatch event + */ + private async onDispatch( + originChain: string, + sender: string, + destinationDomainId: number, + message: string, + ): Promise { + const destChain = + this.core.multiProvider.tryGetChainName(destinationDomainId); + if (!destChain) { + logger.error({ destinationDomainId }, 'Unknown destination domain'); + return; + } + + const originDomain = this.domains[originChain]; + if (!originDomain) { + logger.warn( + { originChain }, + 'No domain config for origin chain, skipping', + ); + return; + } + const senderLower = sender.toLowerCase(); + + // Classify by sender + const isWarp = senderLower === originDomain.warpToken.toLowerCase(); + const isBridge = senderLower === originDomain.bridge.toLowerCase(); + + if (!isWarp && !isBridge) { + logger.warn( + { sender, warp: originDomain.warpToken, bridge: originDomain.bridge }, + 'Unknown sender in Dispatch event', + ); + return; + } + + const type = isWarp ? 'user-transfer' : 'bridge-transfer'; + + // Compute real messageId + const messageId = ethers.utils.keccak256(message); + + const body = '0x' + message.slice(2 + MESSAGE_BODY_OFFSET * 2); + let amount = 0n; + try { + const decoded = ethers.utils.defaultAbiCoder.decode( + ['bytes32', 'uint256'], + body, + ); + const scaledAmount = decoded[1].toBigInt(); + // Warp tokens use scale = 10^decimals, bridge Router uses scale = 1 (no scaling) + amount = + type === 'user-transfer' + ? scaledAmount / WARP_TOKEN_SCALE + : scaledAmount; + } catch (error) { + logger.warn( + { messageId, origin: originChain, dest: destChain, error }, + 'Failed to decode message amount', + ); + } + + // Calculate delivery time + let delay: number; + if (type === 'user-transfer') { + delay = this.userTransferDelay; + } else { + const routeConfig = this.getRouteConfig(originChain, destChain); + delay = this.calculateBridgeDelay(routeConfig); + } + + const pending: PendingMessage = { + messageId, + message, + destination: destChain, + deliveryTime: Date.now() + delay, + type, + origin: originChain, + amount, + attempts: 0, + }; + + if (type === 'bridge-transfer') { + // Record rebalance start in KPI + const rebalanceId = this.kpiCollector.recordRebalanceStart( + originChain, + destChain, + amount, + 0n, + ); + this.kpiCollector.linkBridgeTransfer(messageId, rebalanceId); + } else { + // User transfer: auto-track from event + this.kpiCollector.recordTransferStart( + messageId, + originChain, + destChain, + amount, + ); + + this.actionTracker?.addTransfer( + messageId, + this.core.multiProvider.getDomainId(originChain), + this.core.multiProvider.getDomainId(destChain), + amount, + ); + } + + this.pendingMessages.push(pending); + } + + /** + * Async processing loop — delivers ready messages, sleeps between iterations. + * Retries indefinitely; waitForAllDeliveries handles the timeout. + */ + private async processLoop(): Promise { + while (this.isRunning) { + const now = Date.now(); + const ready = this.pendingMessages.filter((m) => m.deliveryTime <= now); + + for (const msg of ready) { + if (!this.isRunning) break; + + const mailbox = this.core.getContracts(msg.destination).mailbox; + + // Static call pre-check + try { + await mailbox.callStatic.process('0x', msg.message); + } catch (error) { + logger.debug( + { + messageId: msg.messageId, + dest: msg.destination, + attempts: msg.attempts, + error, + }, + 'Static pre-check failed, will retry', + ); + msg.attempts++; + msg.deliveryTime = now + 200; + continue; + } + + try { + const tx = await mailbox.process('0x', msg.message); + await tx.wait(); + + // Remove from pending + const idx = this.pendingMessages.indexOf(msg); + if (idx >= 0) this.pendingMessages.splice(idx, 1); + + // Record completion + if (msg.type === 'user-transfer') { + this.kpiCollector.recordTransferComplete(msg.messageId); + this.actionTracker?.removeTransfer(msg.messageId); + } else if (msg.type === 'bridge-transfer') { + this.kpiCollector.recordRebalanceComplete(msg.messageId); + if (this.actionTracker && msg.amount > 0n) { + this.actionTracker.completeRebalanceByRoute( + this.core.multiProvider.getDomainId(msg.origin), + this.core.multiProvider.getDomainId(msg.destination), + msg.amount, + ); + } + } + } catch (error) { + msg.attempts++; + msg.deliveryTime = now + 200; + logger.debug( + { messageId: msg.messageId, dest: msg.destination, error }, + 'Delivery tx failed, will retry', + ); + } + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + + /** + * Stop listening and processing + */ + async stop(): Promise { + this.isRunning = false; + + // Wait for the processing loop to exit + if (this.processLoopPromise) { + await this.processLoopPromise; + this.processLoopPromise = undefined; + } + + for (const chainName of this.core.multiProvider.getKnownChainNames()) { + this.core.getContracts(chainName).mailbox.removeAllListeners(); + } + } + + hasPendingMessages(): boolean { + return this.pendingMessages.length > 0; + } + + /** + * Wait for all pending messages to be delivered + */ + async waitForAllDeliveries(timeoutMs: number = 30000): Promise { + const startTime = Date.now(); + + while (this.hasPendingMessages()) { + if (Date.now() - startTime > timeoutMs) { + const remaining = this.pendingMessages.length; + logger.warn( + { remaining }, + 'Timeout waiting for deliveries - marking failures', + ); + + for (const msg of this.pendingMessages) { + if (msg.type === 'user-transfer') { + this.kpiCollector.recordTransferFailed(msg.messageId); + this.actionTracker?.removeTransfer(msg.messageId); + } else if (msg.type === 'bridge-transfer') { + this.kpiCollector.recordRebalanceFailed(msg.messageId); + if (this.actionTracker && msg.amount > 0n) { + this.actionTracker.failRebalanceByRoute( + this.core.multiProvider.getDomainId(msg.origin), + this.core.multiProvider.getDomainId(msg.destination), + msg.amount, + ); + } + } + } + this.pendingMessages = []; + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } +} diff --git a/typescript/rebalancer-sim/src/RebalancerSimulationHarness.ts b/typescript/rebalancer-sim/src/RebalancerSimulationHarness.ts index 871e54cbd97..9b1f451b63e 100644 --- a/typescript/rebalancer-sim/src/RebalancerSimulationHarness.ts +++ b/typescript/rebalancer-sim/src/RebalancerSimulationHarness.ts @@ -239,15 +239,6 @@ export class RebalancerSimulationHarness { return this.deployment; } - /** - * Reset the simulation engine state (does not reset blockchain state) - */ - reset(): void { - if (this.engine) { - this.engine.reset(); - } - } - /** * Generate a markdown report from simulation results */ diff --git a/typescript/rebalancer-sim/src/SimulationDeployment.ts b/typescript/rebalancer-sim/src/SimulationDeployment.ts index 6642e423ed4..091cba633ee 100644 --- a/typescript/rebalancer-sim/src/SimulationDeployment.ts +++ b/typescript/rebalancer-sim/src/SimulationDeployment.ts @@ -150,16 +150,43 @@ export async function deployMultiDomainSimulation( await warpToken.enrollRemoteRouters(remoteDomains, remoteRouters); } - // Step 6: Deploy MockValueTransferBridge for each domain and add to allowed bridges + // Step 6: Deploy MockValueTransferBridge for each domain (now extends Router) const bridges: Record = {}; for (const chain of chains) { const bridge = await new MockValueTransferBridge__factory(deployer).deploy( collateralTokens[chain.domainId].address, + mailboxes[chain.domainId].address, ); await bridge.deployed(); + + // Initialize the bridge (Router requires initialization) + await bridge.initialize( + ethers.constants.AddressZero, // hook + ethers.constants.AddressZero, // ISM + deployerAddress, // owner + ); + bridges[chain.domainId] = bridge; } + // Step 6b: Enroll remote routers on bridges (so _Router_dispatch works) + for (const chain of chains) { + const bridge = bridges[chain.domainId]; + const remoteDomains: number[] = []; + const remoteRouters: string[] = []; + + for (const otherChain of chains) { + if (chain.domainId !== otherChain.domainId) { + remoteDomains.push(otherChain.domainId); + remoteRouters.push( + ethers.utils.hexZeroPad(bridges[otherChain.domainId].address, 32), + ); + } + } + + await bridge.enrollRemoteRouters(remoteDomains, remoteRouters); + } + // Step 7: Add bridges to warp tokens for all destination domains for (const chain of chains) { const warpToken = warpTokens[chain.domainId]; diff --git a/typescript/rebalancer-sim/src/SimulationEngine.ts b/typescript/rebalancer-sim/src/SimulationEngine.ts index 78194b8e775..dcdae674422 100644 --- a/typescript/rebalancer-sim/src/SimulationEngine.ts +++ b/typescript/rebalancer-sim/src/SimulationEngine.ts @@ -4,12 +4,17 @@ import { ERC20__factory, HypERC20Collateral__factory, } from '@hyperlane-xyz/core'; -import { TokenStandard, type WarpCoreConfig } from '@hyperlane-xyz/sdk'; -import { rootLogger } from '@hyperlane-xyz/utils'; +import { + type ChainMetadata, + HyperlaneCore, + MultiProvider, + TokenStandard, + type WarpCoreConfig, +} from '@hyperlane-xyz/sdk'; +import { ProtocolType, rootLogger } from '@hyperlane-xyz/utils'; -import { BridgeMockController } from './BridgeMockController.js'; import { KPICollector } from './KPICollector.js'; -import { MessageTracker } from './MessageTracker.js'; +import { MockInfrastructureController } from './MockInfrastructureController.js'; import type { BridgeMockConfig, IRebalancerRunner, @@ -37,12 +42,7 @@ export const DEFAULT_TIMING: SimulationTiming = { */ export class SimulationEngine { private provider: ethers.providers.JsonRpcProvider; - private bridgeController?: BridgeMockController; - private kpiCollector?: KPICollector; - private messageTracker?: MessageTracker; private isRunning = false; - private mailboxProcessingInterval?: NodeJS.Timeout; - private mailboxProcessingInFlight = false; constructor(private readonly deployment: MultiDomainDeploymentResult) { this.provider = new ethers.providers.JsonRpcProvider(deployment.anvilRpc); @@ -65,69 +65,32 @@ export class SimulationEngine { const startTime = Date.now(); this.isRunning = true; + let controller: MockInfrastructureController | undefined; + try { - // Initialize components - // Use bridgeControllerKey for bridge operations to avoid nonce conflicts - this.bridgeController = new BridgeMockController( + // Initialize KPI collector + const kpiCollector = new KPICollector( this.provider, this.deployment.domains, - this.deployment.bridgeControllerKey, - bridgeConfig, ); + await kpiCollector.initialize(); - this.kpiCollector = new KPICollector( - this.provider, - this.deployment.domains, - ); + // Get action tracker from rebalancer if supported + const actionTracker = rebalancer.getActionTracker?.(); - // Initialize MessageTracker for off-chain message tracking - this.messageTracker = new MessageTracker( - this.provider, + // Build HyperlaneCore for the controller (manages MultiProvider + Mailboxes) + const core = this.buildHyperlaneCore(); + + // Create unified controller + controller = new MockInfrastructureController( + core, this.deployment.domains, - this.deployment.mailboxProcessorKey, + bridgeConfig, + timing.userTransferDeliveryDelay, + kpiCollector, + actionTracker, ); - - await this.kpiCollector.initialize(); - await this.messageTracker.initialize(); - await this.bridgeController.start(); - - // Wire up MessageTracker events for KPI tracking - this.messageTracker.on('message_delivered', (message) => { - this.kpiCollector!.recordTransferComplete(message.transferId); - }); - - this.messageTracker.on('message_failed', ({ message }) => { - // Don't record as failed yet - it will retry - logger.debug( - { - messageId: message.id, - attempts: message.attempts, - error: message.lastError, - }, - 'Message failed, will retry', - ); - }); - - // Set up bridge event handlers for rebalance KPI tracking - // Bridge transfers are rebalancer operations (user transfers go through warp token) - this.bridgeController.on('transfer_initiated', (event) => { - const rebalanceId = this.kpiCollector!.recordRebalanceStart( - event.transfer.origin, - event.transfer.destination, - event.transfer.amount, - BigInt(0), // Gas cost not tracked yet - ); - // Link bridge transfer ID to rebalance ID for completion tracking - this.kpiCollector!.linkBridgeTransfer(event.transfer.id, rebalanceId); - }); - - this.bridgeController.on('transfer_delivered', (event) => { - this.kpiCollector!.recordRebalanceComplete(event.transfer.id); - }); - - this.bridgeController.on('transfer_failed', (event) => { - this.kpiCollector!.recordRebalanceFailed(event.transfer.id); - }); + await controller.start(); // Build warp config for rebalancer const warpConfig = this.buildWarpConfig(); @@ -141,31 +104,22 @@ export class SimulationEngine { }; await rebalancer.initialize(rebalancerConfig); - - // Start rebalancer daemon await rebalancer.start(); - // Start periodic mailbox processing for delayed user transfer delivery - this.startMailboxProcessing(); - // Execute transfers according to scenario - await this.executeTransfers(scenario, timing); + await this.executeTransfers(scenario, timing, kpiCollector); - // Wait for all user transfer deliveries (respecting delay) - // Use a timeout to prevent indefinite hanging - await Promise.race([ - this.waitForUserTransferDeliveries(), - new Promise((resolve) => setTimeout(resolve, 60000)), // 60s max - ]); + // Wait for ethers event polling to catch up + await new Promise((r) => setTimeout(r, 200)); - // Wait for bridge deliveries to complete (rebalancer transfers) - await this.bridgeController.waitForAllDeliveries(30000); + // Wait for all deliveries (user transfers + bridge transfers) + await controller.waitForAllDeliveries(60000); // Wait for rebalancer to become idle await rebalancer.waitForIdle(5000); // Generate final KPIs - const kpis = await this.kpiCollector.generateKPIs(); + const kpis = await kpiCollector.generateKPIs(); const endTime = Date.now(); return { @@ -175,35 +129,28 @@ export class SimulationEngine { endTime, duration: endTime - startTime, kpis, - transferRecords: this.kpiCollector.getTransferRecords(), - rebalanceRecords: this.kpiCollector.getRebalanceRecords(), + transferRecords: kpiCollector.getTransferRecords(), + rebalanceRecords: kpiCollector.getRebalanceRecords(), }; } finally { - // Always cleanup, even if we timeout or error this.isRunning = false; - this.stopMailboxProcessing(); try { await rebalancer.stop(); - } catch { - // Ignore stop errors + } catch (error: unknown) { + logger.debug({ error }, 'Rebalancer stop failed during cleanup'); } - if (this.bridgeController) { + if (controller) { try { - await this.bridgeController.stop(); - } catch { - // Ignore stop errors + await controller.stop(); + } catch (error: unknown) { + logger.debug({ error }, 'Controller stop failed during cleanup'); } } - if (this.messageTracker) { - this.messageTracker.removeAllListeners(); - } - // Clean up provider to release connections this.provider.removeAllListeners(); - // Force polling to stop this.provider.polling = false; } } @@ -214,6 +161,7 @@ export class SimulationEngine { private async executeTransfers( scenario: TransferScenario, timing: SimulationTiming, + kpiCollector: KPICollector, ): Promise { const deployer = new ethers.Wallet( this.deployment.deployerKey, @@ -231,14 +179,6 @@ export class SimulationEngine { await new Promise((resolve) => setTimeout(resolve, waitTime)); } - // Record transfer start - this.kpiCollector!.recordTransferStart( - transfer.id, - transfer.origin, - transfer.destination, - transfer.amount, - ); - // Execute the transfer via warp token const originDomain = this.deployment.domains[transfer.origin]; const destDomain = this.deployment.domains[transfer.destination]; @@ -287,13 +227,7 @@ export class SimulationEngine { ); } - // Track message for delayed delivery via MessageTracker - await this.messageTracker!.trackMessage( - transfer.id, - transfer.origin, - transfer.destination, - timing.userTransferDeliveryDelay, - ); + // Controller auto-tracks from Dispatch events — no registration needed } catch (error) { logger.error( { @@ -302,91 +236,16 @@ export class SimulationEngine { }, 'Transfer failed', ); - this.kpiCollector!.recordTransferFailed(transfer.id); - } - } - logger.info('All transfers executed'); - } - - /** - * Start periodic processing of mailbox messages (simulates relayer with delay) - */ - private startMailboxProcessing(): void { - // Process mailbox every 100ms to check for deliveries due - const PROCESS_INTERVAL = 100; - - this.mailboxProcessingInterval = setInterval(async () => { - // Guard against overlapping ticks to prevent nonce collisions - if (this.mailboxProcessingInFlight) return; - this.mailboxProcessingInFlight = true; - try { - await this.processReadyMailboxDeliveries(); - } finally { - this.mailboxProcessingInFlight = false; - } - }, PROCESS_INTERVAL); - } - - /** - * Stop mailbox processing - */ - private stopMailboxProcessing(): void { - if (this.mailboxProcessingInterval) { - clearInterval(this.mailboxProcessingInterval); - this.mailboxProcessingInterval = undefined; - } - } - - /** - * Process mailbox deliveries that are ready (past their delivery time) - * Uses MessageTracker for off-chain tracking with per-message control - */ - private async processReadyMailboxDeliveries(): Promise { - if (!this.messageTracker) return; - await this.messageTracker.processReadyMessages(); - } - - /** - * Wait for all pending user transfer deliveries to complete - */ - private async waitForUserTransferDeliveries( - timeout: number = 30000, - ): Promise { - if (!this.messageTracker) return; - - const startTime = Date.now(); - - while (this.messageTracker.hasPendingMessages()) { - if (Date.now() - startTime > timeout) { - const pending = this.messageTracker.getPendingMessages(); - logger.warn( - { pendingCount: pending.length }, - 'Timeout waiting for user transfer deliveries - marking as failed', + kpiCollector.recordTransferStart( + transfer.id, + transfer.origin, + transfer.destination, + transfer.amount, ); - // Mark pending messages as failed so KPIs reflect reality - for (const msg of pending) { - logger.warn( - { - messageId: msg.id, - origin: msg.origin, - destination: msg.destination, - status: msg.status, - attempts: msg.attempts, - error: msg.lastError || 'timeout', - }, - 'Marking pending message as failed', - ); - // Record as failed in KPI collector - this.kpiCollector?.recordTransferFailed(msg.transferId); - } - // Clear pending messages so they don't block - this.messageTracker.clear(); - break; + kpiCollector.recordTransferFailed(transfer.id); } - - // Interval handles processing; just wait - await new Promise((resolve) => setTimeout(resolve, 100)); } + logger.info('All transfers executed'); } /** @@ -414,13 +273,40 @@ export class SimulationEngine { } /** - * Reset internal tracking state (does not reset blockchain state) + * Build a HyperlaneCore for the infrastructure controller with the + * mailbox processor signer set on all chains. */ - reset(): void { - // Clear message tracker state - if (this.messageTracker) { - this.messageTracker.clear(); + private buildHyperlaneCore(): HyperlaneCore { + const chainMetadata: Record = {}; + const addressesMap: Record = {}; + for (const [chainName, domain] of Object.entries(this.deployment.domains)) { + chainMetadata[chainName] = { + name: chainName, + chainId: 31337, + domainId: domain.domainId, + protocol: ProtocolType.Ethereum, + rpcUrls: [{ http: this.deployment.anvilRpc }], + nativeToken: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }; + addressesMap[chainName] = { mailbox: domain.mailbox }; + } + + const multiProvider = new MultiProvider(chainMetadata); + const processorWallet = new ethers.Wallet( + this.deployment.mailboxProcessorKey, + this.provider, + ); + multiProvider.setSharedSigner(processorWallet); + + // Set fast polling on internal providers + for (const chainName of multiProvider.getKnownChainNames()) { + const p = multiProvider.tryGetProvider(chainName); + if (p && 'pollingInterval' in p) { + (p as ethers.providers.JsonRpcProvider).pollingInterval = 100; + } } + + return HyperlaneCore.fromAddressesMap(addressesMap, multiProvider); } /** diff --git a/typescript/rebalancer-sim/src/index.ts b/typescript/rebalancer-sim/src/index.ts index 9daa8d598e3..ada8749a5c1 100644 --- a/typescript/rebalancer-sim/src/index.ts +++ b/typescript/rebalancer-sim/src/index.ts @@ -6,9 +6,8 @@ */ // Core simulation classes -export { BridgeMockController } from './BridgeMockController.js'; export { KPICollector } from './KPICollector.js'; -export { MessageTracker } from './MessageTracker.js'; +export { MockInfrastructureController } from './MockInfrastructureController.js'; export { RebalancerSimulationHarness } from './RebalancerSimulationHarness.js'; export { deployMultiDomainSimulation, diff --git a/typescript/rebalancer-sim/src/runners/MockActionTracker.ts b/typescript/rebalancer-sim/src/runners/MockActionTracker.ts new file mode 100644 index 00000000000..ade27a55ed6 --- /dev/null +++ b/typescript/rebalancer-sim/src/runners/MockActionTracker.ts @@ -0,0 +1,443 @@ +import type { + CreateRebalanceActionParams, + CreateRebalanceIntentParams, + IActionTracker, + RebalanceAction, + RebalanceIntent, + Transfer, +} from '@hyperlane-xyz/rebalancer'; +import type { Domain } from '@hyperlane-xyz/utils'; +import { rootLogger } from '@hyperlane-xyz/utils'; + +const logger = rootLogger.child({ module: 'MockActionTracker' }); + +/** + * Mock implementation of IActionTracker for simulation testing. + * + * This tracker maintains in-memory state without requiring a real + * ExplorerClient or on-chain queries. The simulation engine updates + * this tracker via callbacks when transfers/rebalances are initiated + * or completed. + */ +export class MockActionTracker implements IActionTracker { + private transfers = new Map(); + private intents = new Map(); + private actions = new Map(); + private idCounter = 0; + + async initialize(): Promise { + logger.debug('MockActionTracker initialized'); + } + + // === Sync Operations (no-ops in simulation) === + + async syncTransfers(): Promise { + // No-op: simulation manages state directly via callbacks + } + + async syncRebalanceIntents(): Promise { + // No-op: simulation manages state directly + } + + async syncRebalanceActions(): Promise { + // No-op: simulation manages state directly + } + + // === Transfer Management === + + /** + * Add a transfer (called by simulation when user transfer is initiated). + */ + addTransfer( + id: string, + origin: Domain, + destination: Domain, + amount: bigint, + ): void { + const now = Date.now(); + this.transfers.set(id, { + id, + origin, + destination, + amount, + status: 'in_progress', + messageId: id, // Use transfer ID as message ID in simulation + sender: '0x0000000000000000000000000000000000000000', + recipient: '0x0000000000000000000000000000000000000000', + createdAt: now, + updatedAt: now, + }); + logger.debug( + { id, origin, destination, amount: amount.toString() }, + 'Added transfer', + ); + } + + /** + * Remove a transfer (called by simulation when transfer delivers or fails). + */ + removeTransfer(id: string): void { + this.transfers.delete(id); + logger.debug({ id }, 'Transfer removed'); + } + + async getInProgressTransfers(): Promise { + return Array.from(this.transfers.values()).filter( + (t) => t.status === 'in_progress', + ); + } + + async getTransfersByDestination(destination: Domain): Promise { + return Array.from(this.transfers.values()).filter( + (t) => t.destination === destination && t.status === 'in_progress', + ); + } + + // === RebalanceIntent Management === + + async createRebalanceIntent( + params: CreateRebalanceIntentParams, + ): Promise { + const id = `intent-${++this.idCounter}`; + const now = Date.now(); + const intent: RebalanceIntent = { + id, + origin: params.origin, + destination: params.destination, + amount: params.amount, + bridge: params.bridge, + priority: params.priority, + strategyType: params.strategyType, + status: 'not_started', + fulfilledAmount: 0n, + createdAt: now, + updatedAt: now, + }; + this.intents.set(id, intent); + logger.debug( + { + id, + origin: params.origin, + destination: params.destination, + amount: params.amount.toString(), + }, + 'Created rebalance intent', + ); + return intent; + } + + async getActiveRebalanceIntents(): Promise { + return Array.from(this.intents.values()).filter( + (i) => i.status === 'not_started' || i.status === 'in_progress', + ); + } + + async getRebalanceIntentsByDestination( + destination: Domain, + ): Promise { + return Array.from(this.intents.values()).filter( + (i) => + i.destination === destination && + (i.status === 'not_started' || i.status === 'in_progress'), + ); + } + + async completeRebalanceIntent(id: string): Promise { + const intent = this.intents.get(id); + if (intent) { + intent.status = 'complete'; + intent.updatedAt = Date.now(); + logger.debug({ id }, 'Rebalance intent completed'); + } + } + + async cancelRebalanceIntent(id: string): Promise { + const intent = this.intents.get(id); + if (intent) { + intent.status = 'cancelled'; + intent.updatedAt = Date.now(); + logger.debug({ id }, 'Rebalance intent cancelled'); + } + } + + async failRebalanceIntent(id: string): Promise { + const intent = this.intents.get(id); + if (intent) { + intent.status = 'failed'; + intent.updatedAt = Date.now(); + logger.debug({ id }, 'Rebalance intent failed'); + } + } + + // === RebalanceAction Management === + + async createRebalanceAction( + params: CreateRebalanceActionParams, + ): Promise { + const id = `action-${++this.idCounter}`; + const now = Date.now(); + const action: RebalanceAction = { + id, + intentId: params.intentId, + origin: params.origin, + destination: params.destination, + amount: params.amount, + messageId: params.messageId, + txHash: params.txHash, + status: 'in_progress', + createdAt: now, + updatedAt: now, + }; + this.actions.set(id, action); + + // Transition parent intent to in_progress + const intent = this.intents.get(params.intentId); + if (intent && intent.status === 'not_started') { + intent.status = 'in_progress'; + intent.updatedAt = now; + } + + logger.info( + { + id, + intentId: params.intentId, + messageId: params.messageId, + origin: params.origin, + destination: params.destination, + amount: params.amount.toString(), + }, + 'Created rebalance action', + ); + return action; + } + + async completeRebalanceAction(id: string): Promise { + const action = this.actions.get(id); + if (action) { + action.status = 'complete'; + action.updatedAt = Date.now(); + + // Update parent intent's fulfilledAmount + const intent = this.intents.get(action.intentId); + if (intent) { + intent.fulfilledAmount += action.amount; + intent.updatedAt = Date.now(); + + if (intent.fulfilledAmount >= intent.amount) { + intent.status = 'complete'; + } + } + + logger.debug({ id }, 'Rebalance action completed'); + } + } + + async failRebalanceAction(id: string): Promise { + const action = this.actions.get(id); + if (action) { + action.status = 'failed'; + action.updatedAt = Date.now(); + logger.debug({ id }, 'Rebalance action failed'); + } + } + + // === Debug === + + async logStoreContents(): Promise { + logger.debug( + { + transfers: this.transfers.size, + intents: this.intents.size, + actions: this.actions.size, + }, + 'MockActionTracker store contents', + ); + } + + /** + * Clear all state (useful for test cleanup). + */ + clear(): void { + this.transfers.clear(); + this.intents.clear(); + this.actions.clear(); + this.idCounter = 0; + logger.debug('MockActionTracker cleared'); + } + + // === Simulation-specific methods === + + /** + * Create an action for an intent by matching origin/destination/amount. + * Used by simulation when bridge transfer is initiated, since RebalancerService + * can't extract messageId from MockValueTransferBridge (no Dispatch event). + * Returns the created action, or null if no matching intent found. + */ + createActionForPendingIntent( + origin: Domain, + destination: Domain, + amount: bigint, + bridgeTransferId: string, + ): RebalanceAction | null { + // Find matching intent that needs an action (not_started or in_progress without enough fulfilled) + const allIntents = Array.from(this.intents.values()); + const matchingIntents = allIntents + .filter( + (i) => + i.origin === origin && + i.destination === destination && + i.amount === amount && + (i.status === 'not_started' || i.status === 'in_progress'), + ) + .sort((a, b) => a.createdAt - b.createdAt); + + if (matchingIntents.length === 0) { + logger.warn( + { origin, destination, amount: amount.toString() }, + 'No matching intent found for bridge transfer', + ); + return null; + } + + const intent = matchingIntents[0]; + const now = Date.now(); + const actionId = `action-${++this.idCounter}`; + + const action: RebalanceAction = { + id: actionId, + intentId: intent.id, + origin, + destination, + amount, + messageId: bridgeTransferId, // Use bridge transfer ID as pseudo-messageId + status: 'in_progress', + createdAt: now, + updatedAt: now, + }; + + this.actions.set(actionId, action); + + // Transition intent to in_progress if needed + if (intent.status === 'not_started') { + intent.status = 'in_progress'; + intent.updatedAt = now; + } + + logger.debug( + { + actionId, + intentId: intent.id, + bridgeTransferId, + origin, + destination, + amount: amount.toString(), + }, + 'Created action for pending intent (simulation bridge transfer)', + ); + + return action; + } + + /** + * Complete a rebalance action by matching origin/destination/amount. + * Used by simulation when bridge delivers, since we don't have direct action ID correlation. + * Finds the oldest in-progress action that matches and completes it. + */ + completeRebalanceByRoute( + origin: Domain, + destination: Domain, + amount: bigint, + ): boolean { + // Find matching in-progress action (oldest first) + const allActions = Array.from(this.actions.values()); + const matchingActions = allActions + .filter( + (a) => + a.origin === origin && + a.destination === destination && + a.amount === amount && + a.status === 'in_progress', + ) + .sort((a, b) => a.createdAt - b.createdAt); + + if (matchingActions.length === 0) { + logger.debug( + { origin, destination, amount: amount.toString() }, + 'No matching in-progress action found for delivery', + ); + return false; + } + + const action = matchingActions[0]; + action.status = 'complete'; + action.updatedAt = Date.now(); + + // Update parent intent's fulfilledAmount + const intent = this.intents.get(action.intentId); + if (intent) { + intent.fulfilledAmount += action.amount; + intent.updatedAt = Date.now(); + + // If fully fulfilled, mark intent as complete + if (intent.fulfilledAmount >= intent.amount) { + intent.status = 'complete'; + logger.debug( + { + intentId: intent.id, + fulfilledAmount: intent.fulfilledAmount.toString(), + }, + 'Intent fully fulfilled, marking complete', + ); + } + } + + logger.debug( + { actionId: action.id, intentId: action.intentId, origin, destination }, + 'Completed rebalance action by route', + ); + return true; + } + + /** + * Fail a rebalance action by route match (origin, destination, amount). + * Marks the action as failed and the parent intent as failed. + * Does NOT increment fulfilledAmount. + */ + failRebalanceByRoute( + origin: Domain, + destination: Domain, + amount: bigint, + ): boolean { + const allActions = Array.from(this.actions.values()); + const matchingActions = allActions + .filter( + (a) => + a.origin === origin && + a.destination === destination && + a.amount === amount && + a.status === 'in_progress', + ) + .sort((a, b) => a.createdAt - b.createdAt); + + if (matchingActions.length === 0) { + return false; + } + + const action = matchingActions[0]; + action.status = 'failed'; + action.updatedAt = Date.now(); + + // Mark parent intent as failed + const intent = this.intents.get(action.intentId); + if (intent) { + intent.status = 'failed'; + intent.updatedAt = Date.now(); + } + + logger.debug( + { actionId: action.id, intentId: action.intentId, origin, destination }, + 'Failed rebalance action by route', + ); + return true; + } +} diff --git a/typescript/rebalancer-sim/src/runners/ProductionRebalancerRunner.ts b/typescript/rebalancer-sim/src/runners/ProductionRebalancerRunner.ts index c2d0459976e..8b6f2c55070 100644 --- a/typescript/rebalancer-sim/src/runners/ProductionRebalancerRunner.ts +++ b/typescript/rebalancer-sim/src/runners/ProductionRebalancerRunner.ts @@ -13,6 +13,7 @@ import { ProtocolType, rootLogger } from '@hyperlane-xyz/utils'; import type { IRebalancerRunner, RebalancerSimConfig } from '../types.js'; +import { MockActionTracker } from './MockActionTracker.js'; import { SimulationRegistry } from './SimulationRegistry.js'; // Silent logger for the rebalancer service (internal) @@ -112,12 +113,16 @@ export class ProductionRebalancerRunner private config?: RebalancerSimConfig; private service?: RebalancerService; private running = false; + private mockTracker = new MockActionTracker(); async initialize(config: RebalancerSimConfig): Promise { // Cleanup any previously running instance await cleanupProductionRebalancer(); this.config = config; + + // Reset tracker state for fresh simulation + this.mockTracker.clear(); } async start(): Promise { @@ -217,7 +222,7 @@ export class ProductionRebalancerRunner strategyConfig, ] as StrategyConfig[]); - // Create service + // Create service with mock action tracker this.service = new RebalancerService( multiProvider, multiProtocolProvider, @@ -229,6 +234,7 @@ export class ProductionRebalancerRunner monitorOnly: false, withMetrics: false, logger: silentLogger, + actionTracker: this.mockTracker, }, ); @@ -274,6 +280,7 @@ export class ProductionRebalancerRunner } this.config = undefined; + this.mockTracker.clear(); this.removeAllListeners(); } @@ -286,4 +293,11 @@ export class ProductionRebalancerRunner const settleTime = Math.min(timeoutMs, 2000); await new Promise((resolve) => setTimeout(resolve, settleTime)); } + + /** + * Get the mock action tracker for direct inflight tracking updates. + */ + getActionTracker(): MockActionTracker { + return this.mockTracker; + } } diff --git a/typescript/rebalancer-sim/src/types.ts b/typescript/rebalancer-sim/src/types.ts index de896f9c7f2..efd693540d0 100644 --- a/typescript/rebalancer-sim/src/types.ts +++ b/typescript/rebalancer-sim/src/types.ts @@ -7,6 +7,8 @@ import type { WarpCoreConfig } from '@hyperlane-xyz/sdk'; import type { Address } from '@hyperlane-xyz/utils'; +import type { MockActionTracker } from './runners/MockActionTracker.js'; + // ============================================================================= // BRIDGE TYPES // ============================================================================= @@ -413,6 +415,13 @@ export interface IRebalancerRunner { * Subscribe to rebalancer events */ on(event: 'rebalance', listener: (e: RebalancerEvent) => void): this; + + /** + * Get the mock action tracker (optional). + * If supported, SimulationEngine passes it to the infrastructure controller + * for direct inflight tracking updates. + */ + getActionTracker?(): MockActionTracker | undefined; } /** diff --git a/typescript/rebalancer-sim/test/integration/full-simulation.test.ts b/typescript/rebalancer-sim/test/integration/full-simulation.test.ts index 960d6145a68..02d98761944 100644 --- a/typescript/rebalancer-sim/test/integration/full-simulation.test.ts +++ b/typescript/rebalancer-sim/test/integration/full-simulation.test.ts @@ -29,8 +29,7 @@ import { } from '../utils/simulation-helpers.js'; describe('Rebalancer Simulation', function () { - const anvilPort = 8545; - const anvil = setupAnvilTestSuite(this, anvilPort); + const anvil = setupAnvilTestSuite(this); before(async function () { ensureResultsDir(); @@ -223,32 +222,68 @@ describe('Rebalancer Simulation', function () { * and polling at 1000ms, a rebalancer without inflight awareness will * over-rebalance because it doesn't account for pending transfers. * - * CURRENT LIMITATION: ProductionRebalancerService tracks self-created rebalance - * intents via ActionTracker's in-memory store, but user transfer tracking relies - * on ExplorerClient which has no indexed data in simulation (Anvil). - * Until a mock ExplorerClient is implemented, this test runs as report-only. + * SimpleRebalancer: No inflight tracking, over-rebalances significantly + * ProductionRebalancer: Tracks pending rebalances via MockActionTracker, + * significantly reduces redundant rebalances (typically 60-80% fewer) */ - it('inflight-guard: demonstrates over-rebalancing with slow bridge (report only)', async function () { - // This test takes longer due to 3s bridge delays and runs multiple rebalancers + it('inflight-guard: ProductionRebalancer uses fewer rebalances with inflight tracking', async function () { this.timeout(120000); const { results } = await runScenarioWithRebalancers('inflight-guard', { anvilRpc: anvil.rpc, }); - // Report results - demonstrates over-rebalancing behavior + // Report results console.log('\n INFLIGHT GUARD REPORT:'); for (const result of results) { console.log( ` ${result.rebalancerName}: ${result.kpis.totalRebalances} rebalances`, ); } - console.log(' (Expected with proper inflight tracking: ≤2 rebalances)'); - console.log( - ' Note: Currently both over-rebalance due to Explorer unavailability in simulation', + + // Find results by rebalancer type + const productionResult = results.find( + (r) => r.rebalancerName === 'ProductionRebalancerService', + ); + const simpleResult = results.find( + (r) => r.rebalancerName === 'SimpleRebalancer', ); - // Report-only test - results are informational - expect(results.length).to.be.greaterThan(0); + + // Both should complete all transfers + if (productionResult) { + expect(productionResult.kpis.completionRate).to.equal( + 1, + 'ProductionRebalancer should complete all transfers', + ); + } + if (simpleResult) { + expect(simpleResult.kpis.completionRate).to.equal( + 1, + 'SimpleRebalancer should complete all transfers', + ); + } + + // ProductionRebalancer with inflight tracking should use significantly fewer rebalances + if (productionResult && simpleResult) { + expect(productionResult.kpis.totalRebalances).to.be.lessThan( + simpleResult.kpis.totalRebalances, + 'ProductionRebalancer should use fewer rebalances than SimpleRebalancer', + ); + + // ProductionRebalancer should use at most 50% of SimpleRebalancer's rebalances + // (typically achieves 60-80% reduction) + expect(simpleResult.kpis.totalRebalances).to.be.greaterThan( + 0, + 'SimpleRebalancer should have rebalanced at least once for ratio comparison', + ); + const reductionRatio = + productionResult.kpis.totalRebalances / + simpleResult.kpis.totalRebalances; + expect(reductionRatio).to.be.lessThan( + 0.5, + `ProductionRebalancer should achieve >50% reduction in rebalances (got ${((1 - reductionRatio) * 100).toFixed(0)}% reduction)`, + ); + } }); // BLOCKED USER TRANSFER @@ -257,17 +292,20 @@ describe('Rebalancer Simulation', function () { /** * Blocked User Transfer Test * - * Tests the gap when user transfers are BLOCKED at destination due to - * insufficient collateral. With 50/50 weights and 50% tolerance, state - * appears within tolerance after user initiates transfer (69%/31% is - * within 25-75% range). But destination chain2 can't pay out (40 tokens - * < 50 needed), so transfer stays stuck. + * Tests that the ProductionRebalancer proactively adds collateral when + * user transfers are pending but blocked due to insufficient collateral. + * + * Scenario: 130 total tokens split 90/40 between chain1/chain2. + * User initiates 50 token transfer from chain1 → chain2. + * chain2 only has 40 tokens but needs 50 to pay out. + * + * SimpleRebalancer: Only sees on-chain balances, doesn't know about pending + * transfer, weights appear within tolerance → no action → transfer stuck * - * Neither rebalancer acts because weights are within tolerance. - * With future mock Explorer, rebalancer should see blocked transfer - * and proactively add collateral to chain2. + * ProductionRebalancer: MockActionTracker tracks pending transfer, strategy + * reserves collateral for it, detects deficit → rebalances → transfer succeeds */ - it('blocked-user-transfer: demonstrates blocked transfer without Explorer (report only)', async function () { + it('blocked-user-transfer: ProductionRebalancer proactively adds collateral for pending transfers', async function () { this.timeout(120000); const { results } = await runScenarioWithRebalancers( @@ -283,43 +321,37 @@ describe('Rebalancer Simulation', function () { ` ${result.rebalancerName}: completion=${(result.kpis.completionRate * 100).toFixed(0)}%, rebalances=${result.kpis.totalRebalances}`, ); } - console.log( - ' (Expected: 0% completion for both - weights within tolerance, no rebalancing)', - ); - console.log( - ' (Transfer blocked: chain2 has 40 tokens but needs 50 to pay out)', + + // Find results by rebalancer type + const productionResult = results.find( + (r) => r.rebalancerName === 'ProductionRebalancerService', ); - console.log( - ' (With Explorer mock: rebalancer should see blocked transfer and act)', + const simpleResult = results.find( + (r) => r.rebalancerName === 'SimpleRebalancer', ); - expect(results.length).to.be.greaterThan(0); - }); - // ============================================================================ - // BASELINE (NO REBALANCER) - // ============================================================================ - - it('no-rebalancer baseline: shows transfer failures without rebalancing (report only)', async function () { - // Run with NoOpRebalancer to demonstrate what happens without active rebalancing - // This is report-only - no assertions, just generates visualization - const { results } = await runScenarioWithRebalancers( - 'extreme-drain-chain1', - { - anvilRpc: anvil.rpc, - rebalancerTypes: ['noop'], - }, - ); + // SimpleRebalancer without inflight tracking should fail to complete + if (simpleResult) { + expect(simpleResult.kpis.completionRate).to.equal( + 0, + 'SimpleRebalancer should have 0% completion (blocked transfer)', + ); + expect(simpleResult.kpis.totalRebalances).to.equal( + 0, + 'SimpleRebalancer should not rebalance (weights within tolerance)', + ); + } - // Report only - log results but don't assert - const result = results[0]; - console.log(`\n NO-REBALANCER BASELINE (report only):`); - console.log( - ` Without rebalancing, completion rate: ${(result.kpis.completionRate * 100).toFixed(1)}%`, - ); - console.log( - ` Failed transfers: ${result.kpis.totalTransfers - result.kpis.completedTransfers}`, - ); - console.log(` Rebalances: ${result.kpis.totalRebalances} (expected: 0)`); - expect(result).to.exist; + // ProductionRebalancer with inflight tracking should complete + if (productionResult) { + expect(productionResult.kpis.completionRate).to.equal( + 1.0, + 'ProductionRebalancer should have 100% completion (proactive collateral)', + ); + expect(productionResult.kpis.totalRebalances).to.be.greaterThan( + 0, + 'ProductionRebalancer should rebalance (sees pending transfer deficit)', + ); + } }); }); diff --git a/typescript/rebalancer-sim/test/integration/harness-setup.test.ts b/typescript/rebalancer-sim/test/integration/harness-setup.test.ts index 66edf883f5d..9a395c07be9 100644 --- a/typescript/rebalancer-sim/test/integration/harness-setup.test.ts +++ b/typescript/rebalancer-sim/test/integration/harness-setup.test.ts @@ -37,11 +37,11 @@ import { import { setupAnvilTestSuite } from '../utils/anvil.js'; describe('Multi-Domain Deployment', function () { - const anvilPort = 8546; // Use different port to avoid conflict with other tests - const anvil = setupAnvilTestSuite(this, anvilPort); + const anvil = setupAnvilTestSuite(this); let provider: ethers.providers.JsonRpcProvider; - before(async () => { + beforeEach(async () => { + // Create provider after anvil starts (rpc is set in beforeEach) provider = new ethers.providers.JsonRpcProvider(anvil.rpc); }); diff --git a/typescript/rebalancer-sim/test/utils/anvil.ts b/typescript/rebalancer-sim/test/utils/anvil.ts index 5495086f7ec..c821c10f3ab 100644 --- a/typescript/rebalancer-sim/test/utils/anvil.ts +++ b/typescript/rebalancer-sim/test/utils/anvil.ts @@ -6,15 +6,16 @@ import { import { retryAsync } from '@hyperlane-xyz/utils'; -const DEFAULT_ANVIL_PORT = 8545; +const CONTAINER_PORT = 8545; // Port inside the container (fixed) const DEFAULT_CHAIN_ID = 31337; /** * Start an Anvil container using testcontainers. - * Uses the same pattern as CLI e2e tests for consistency. + * Uses dynamic port assignment - testcontainers picks an available host port. + * + * @returns The started container. Use container.getMappedPort(8545) to get the host port. */ export async function startAnvilContainer( - port: number = DEFAULT_ANVIL_PORT, chainId: number = DEFAULT_CHAIN_ID, ): Promise { return retryAsync( @@ -25,14 +26,11 @@ export async function startAnvilContainer( '--host', '0.0.0.0', '-p', - port.toString(), + CONTAINER_PORT.toString(), '--chain-id', chainId.toString(), ]) - .withExposedPorts({ - container: port, - host: port, - }) + .withExposedPorts(CONTAINER_PORT) // Dynamic host port assignment .withWaitStrategy(Wait.forLogMessage(/Listening on/)) .start(), 3, // maxRetries @@ -40,6 +38,15 @@ export async function startAnvilContainer( ); } +/** + * Get the RPC URL for a started anvil container. + */ +export function getAnvilRpcUrl(container: StartedTestContainer): string { + const host = container.getHost(); + const port = container.getMappedPort(CONTAINER_PORT); + return `http://${host}:${port}`; +} + /** * Setup function for Mocha tests that require Anvil. * Starts a fresh Anvil container for EACH TEST to ensure complete isolation. @@ -47,28 +54,32 @@ export async function startAnvilContainer( * Uses testcontainers for: * - No local anvil installation required * - Automatic container cleanup (even on crashes) + * - Dynamic port assignment (no port conflicts) * - Retry logic for CI reliability * - Consistent behavior across local/CI environments * * Usage: * ```typescript * describe('My Tests', function() { - * const anvil = setupAnvilTestSuite(this, 8545); + * const anvil = setupAnvilTestSuite(this); * * it('test case', async () => { - * const rpc = anvil.rpc; // http://127.0.0.1:8545 + * const rpc = anvil.rpc; // http://localhost: * }); * }); * ``` */ export function setupAnvilTestSuite( suite: Mocha.Suite, - port: number = DEFAULT_ANVIL_PORT, chainId: number = DEFAULT_CHAIN_ID, ): { rpc: string } { - const state: { rpc: string; container: StartedTestContainer | null } = { - rpc: `http://127.0.0.1:${port}`, + // Use a getter pattern so rpc is always current after container starts + const state: { + container: StartedTestContainer | null; + rpc: string; + } = { container: null, + rpc: '', // Will be set after container starts }; suite.timeout(180000); // 3 minutes per test @@ -81,7 +92,8 @@ export function setupAnvilTestSuite( state.container = null; } - state.container = await startAnvilContainer(port, chainId); + state.container = await startAnvilContainer(chainId); + state.rpc = getAnvilRpcUrl(state.container); }); // Stop container after EACH test for clean slate diff --git a/typescript/rebalancer/src/core/RebalancerService.ts b/typescript/rebalancer/src/core/RebalancerService.ts index 3c42d53fdc5..8d8bfa8af84 100644 --- a/typescript/rebalancer/src/core/RebalancerService.ts +++ b/typescript/rebalancer/src/core/RebalancerService.ts @@ -61,6 +61,13 @@ export interface RebalancerServiceConfig { /** Service version for logging */ version?: string; + + /** + * Optional pre-configured ActionTracker. + * If provided, skips ActionTracker creation and uses this directly. + * Useful for simulation/testing where tracking is mocked externally. + */ + actionTracker?: IActionTracker; } export interface ManualRebalanceRequest { @@ -179,13 +186,24 @@ export class RebalancerService { ); } - // Create ActionTracker for tracking inflight actions - const { tracker, adapter } = - await this.contextFactory.createActionTracker(); - this.actionTracker = tracker; - this.inflightContextAdapter = adapter; - await this.actionTracker.initialize(); - this.logger.info('ActionTracker initialized'); + // Create or use provided ActionTracker for tracking inflight actions + if (this.config.actionTracker) { + // Use externally provided ActionTracker (e.g., for simulation/testing) + this.actionTracker = this.config.actionTracker; + this.inflightContextAdapter = new InflightContextAdapter( + this.actionTracker, + this.multiProvider, + ); + await this.actionTracker.initialize(); + this.logger.info('Using externally provided ActionTracker'); + } else { + const { tracker, adapter } = + await this.contextFactory.createActionTracker(); + this.actionTracker = tracker; + this.inflightContextAdapter = adapter; + await this.actionTracker.initialize(); + this.logger.info('ActionTracker initialized'); + } this.logger.info( { diff --git a/typescript/rebalancer/src/index.ts b/typescript/rebalancer/src/index.ts index ba0af6af488..0d2b2998523 100644 --- a/typescript/rebalancer/src/index.ts +++ b/typescript/rebalancer/src/index.ts @@ -93,6 +93,16 @@ export { ExplorerClient } from './utils/ExplorerClient.js'; // Tracking export { InflightContextAdapter } from './tracking/InflightContextAdapter.js'; +export type { + IActionTracker, + CreateRebalanceIntentParams, + CreateRebalanceActionParams, +} from './tracking/IActionTracker.js'; +export type { + Transfer, + RebalanceIntent, + RebalanceAction, +} from './tracking/types.js'; // Factory export { RebalancerContextFactory } from './factories/RebalancerContextFactory.js';