import { BigNumber, ethers, type providers } from 'ethers'; import { pino, type Logger } from 'pino'; import { ERC20Test__factory, HypERC20Collateral__factory, HypNative__factory, } from '@hyperlane-xyz/core'; import { HyperlaneRelayer } from '@hyperlane-xyz/relayer'; import { HyperlaneCore, type MultiProvider } from '@hyperlane-xyz/sdk'; import { assert, ProtocolType } from '@hyperlane-xyz/utils'; import type { BridgeQuote, BridgeQuoteParams, BridgeTransferResult, BridgeTransferStatus, IExternalBridge, } from '../../interfaces/IExternalBridge.js'; import type { Erc20InventoryDeployedAddresses, NativeDeployedAddresses, TestChain, } from '../fixtures/routes.js'; type MockBridgeRoute = { fromChain: number; toChain: number; fromAddress: string; toAddress: string; tokenType: 'native' | 'erc20'; }; export class MockExternalBridge implements IExternalBridge { readonly externalBridgeId = 'mock-bridge'; readonly logger: Logger; private readonly failStatusOverrides = new Map< string, BridgeTransferStatus >(); private _failNextExecute = false; private readonly deployedAddresses: | NativeDeployedAddresses | Erc20InventoryDeployedAddresses; private readonly tokenType: 'native' | 'erc20'; constructor( deployedAddresses: | NativeDeployedAddresses | Erc20InventoryDeployedAddresses, private readonly multiProvider: MultiProvider, private readonly core: HyperlaneCore, tokenType: 'native' | 'erc20' = 'native', logger?: Logger, ) { this.deployedAddresses = deployedAddresses; this.tokenType = tokenType; this.logger = logger ?? pino({ level: 'silent' }).child({ module: 'MockExternalBridge', }); } getNativeTokenAddress(): string { return '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; } async quote(params: BridgeQuoteParams): Promise { if (params.fromAmount !== undefined && params.toAmount !== undefined) { throw new Error( 'Cannot specify both fromAmount and toAmount - provide exactly one', ); } if (params.fromAmount === undefined && params.toAmount === undefined) { throw new Error('Must specify either fromAmount or toAmount'); } const amount = params.fromAmount ?? params.toAmount!; const toAddress = params.toAddress ?? params.fromAddress; const gasCosts = await this.estimateGasCosts( params.fromChain, params.toChain, toAddress, params.fromAddress, ); const route: MockBridgeRoute = { fromChain: params.fromChain, toChain: params.toChain, fromAddress: params.fromAddress, toAddress, tokenType: this.tokenType, }; return { id: `mock-quote-${Date.now()}`, tool: this.externalBridgeId, fromAmount: amount, toAmount: amount, toAmountMin: amount, executionDuration: 1, gasCosts, feeCosts: 0n, route, requestParams: params, }; } async execute( quote: BridgeQuote, privateKeys: Partial>, ): Promise { if (this._failNextExecute) { this._failNextExecute = false; throw new Error('MockExternalBridge execute failure injected'); } const route = this.parseRoute(quote.route); const fromChain = route.fromChain; const toChain = route.toChain; const fromChainName = this.resolveChainName(fromChain); const toChainName = this.resolveChainName(toChain); const bridgeRouteAddress = this.deployedAddresses.bridgeRoute[fromChainName]; const destinationDomain = this.multiProvider.getDomainId(toChainName); const provider = this.multiProvider.getProvider(fromChainName); const evmKey = privateKeys[ProtocolType.Ethereum]; assert(evmKey, 'Missing Ethereum private key for MockExternalBridge'); const signer = new ethers.Wallet(evmKey, provider); const recipientBytes32 = ethers.utils.hexZeroPad( ethers.utils.hexlify(route.toAddress), 32, ); let tx; if (this.tokenType === 'erc20') { assert( 'tokens' in this.deployedAddresses, 'Expected ERC20 deployed addresses', ); const tokenAddress = ( this.deployedAddresses as Erc20InventoryDeployedAddresses ).tokens[fromChainName]; const token = ERC20Test__factory.connect(tokenAddress, signer); await token.approve(bridgeRouteAddress, quote.fromAmount); const bridgeRoute = HypERC20Collateral__factory.connect( bridgeRouteAddress, signer, ); tx = await bridgeRoute.transferRemote( destinationDomain, recipientBytes32, quote.fromAmount, ); } else { const bridgeRoute = HypNative__factory.connect( bridgeRouteAddress, signer, ); tx = await bridgeRoute.transferRemote( destinationDomain, recipientBytes32, quote.fromAmount, { value: quote.fromAmount }, ); } // Wait for the transaction to be mined so that getStatus() can always // find the receipt via getTransactionReceipt(). Without this, there is // a race on automined anvil where the receipt is not yet available. await tx.wait(); return { txHash: tx.hash, fromChain, toChain, }; } async getStatus( txHash: string, fromChain: number, toChain: number, ): Promise { const override = this.failStatusOverrides.get(txHash); if (override) { return override; } try { const fromChainName = this.resolveChainName(fromChain); const toChainName = this.resolveChainName(toChain); const provider = this.multiProvider.getProvider(fromChainName); const dispatchTxReceipt = await provider.getTransactionReceipt(txHash); if (!dispatchTxReceipt) { return { status: 'not_found' }; } const dispatchedMessages = HyperlaneCore.getDispatchedMessages(dispatchTxReceipt); assert( dispatchedMessages.length === 1, `Expected exactly 1 dispatched message, got ${dispatchedMessages.length} for tx ${txHash}`, ); const dispatchedMsgId = dispatchedMessages[0].id; const relayer = new HyperlaneRelayer({ core: this.core }); const receipts = await relayer.relayAll(dispatchTxReceipt); const destinationDomain = this.multiProvider.getDomainId(toChainName); const destinationReceipts = receipts[toChainName] ?? receipts[toChain] ?? receipts[destinationDomain]; // If relayAll didn't produce receipts (e.g. message already delivered), // fall back to checking on-chain delivery status directly. if (!destinationReceipts || destinationReceipts.length === 0) { const destMailbox = this.core.getContracts(toChainName).mailbox; const isDelivered = await destMailbox.delivered(dispatchedMsgId); if (isDelivered) { const receivedAmount = await this.getTransferredAmount( provider, dispatchTxReceipt, ); // Find the actual destination chain process tx const processEvents = await destMailbox.queryFilter( destMailbox.filters.ProcessId(dispatchedMsgId), ); assert( processEvents.length > 0, `No ProcessId event found for message ${dispatchedMsgId} on ${toChainName}`, ); return { status: 'complete', receivingTxHash: processEvents[0].transactionHash, receivedAmount, }; } return { status: 'not_found' }; } const receivedAmount = await this.getTransferredAmount( provider, dispatchTxReceipt, ); return { status: 'complete', receivingTxHash: destinationReceipts[0].transactionHash, receivedAmount, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { status: 'failed', error: message }; } } failStatusFor( txHash: string, status: BridgeTransferStatus = { status: 'failed' }, ): void { this.failStatusOverrides.set(txHash, status); } failNextExecute(): void { this._failNextExecute = true; } reset(): void { this.failStatusOverrides.clear(); this._failNextExecute = false; } /** * Estimates gas costs for a transferRemote call on the bridge route. * Uses a small amount (1 wei) to avoid balance-related estimation failures. */ private async estimateGasCosts( fromChain: number, toChain: number, toAddress: string, fromAddress: string, ): Promise { const fromChainName = this.resolveChainName(fromChain); const toChainName = this.resolveChainName(toChain); const bridgeRouteAddress = this.deployedAddresses.bridgeRoute[fromChainName]; const destinationDomain = this.multiProvider.getDomainId(toChainName); const provider = this.multiProvider.getProvider(fromChainName); const recipientBytes32 = ethers.utils.hexZeroPad( ethers.utils.hexlify(toAddress), 32, ); // Use 1 wei for estimation — gas usage doesn't depend on transfer amount const estimateAmount = 1n; if (this.tokenType === 'erc20') { // ERC20 transferRemote requires token approval which isn't set up during estimation. // Return 0n as a mock — gas costs don't affect test logic. return 0n; } else { const bridgeRoute = HypNative__factory.connect( bridgeRouteAddress, provider, ); const gasEstimate = await bridgeRoute.estimateGas.transferRemote( destinationDomain, recipientBytes32, estimateAmount, { value: estimateAmount, from: fromAddress }, ); const gasPrice = await provider.getGasPrice(); return gasEstimate.mul(gasPrice).toBigInt(); } } private parseRoute(route: unknown): MockBridgeRoute { if (!route || typeof route !== 'object') { throw new Error('Mock quote route is missing'); } const parsed = route as Partial; if ( typeof parsed.fromChain !== 'number' || typeof parsed.toChain !== 'number' || typeof parsed.fromAddress !== 'string' || typeof parsed.toAddress !== 'string' || (parsed.tokenType !== 'native' && parsed.tokenType !== 'erc20') ) { throw new Error('Mock quote route is invalid'); } return { fromChain: parsed.fromChain, toChain: parsed.toChain, fromAddress: parsed.fromAddress, toAddress: parsed.toAddress, tokenType: parsed.tokenType, }; } private resolveChainName(chainRef: number): TestChain { const chainNames = Object.keys( this.deployedAddresses.chains, ) as TestChain[]; for (const chainName of chainNames) { const chainId = Number(this.multiProvider.getChainId(chainName)); const domainId = this.multiProvider.getDomainId(chainName); if (chainId === chainRef || domainId === chainRef) { return chainName; } } throw new Error(`Chain not found for id/domain ${chainRef}`); } private async getTransferredAmount( provider: providers.Provider, receipt: providers.TransactionReceipt, ): Promise { const tx = await provider.getTransaction(receipt.transactionHash); if (!tx) { throw new Error( `Transaction ${receipt.transactionHash} not found on provider`, ); } try { const parsed = HypNative__factory.createInterface().parseTransaction({ data: tx.data, value: tx.value, }); if (!parsed || parsed.name !== 'transferRemote') { throw new Error( `Expected transferRemote tx, got: ${parsed?.name ?? 'unparseable'}`, ); } const amount = parsed.args[2]; if (BigNumber.isBigNumber(amount)) { return amount.toBigInt(); } if (typeof amount === 'bigint') { return amount; } return BigInt(String(amount)); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.logger.warn( { txHash: receipt.transactionHash, error: message }, 'Failed to parse transferRemote amount from tx', ); throw new Error(`Failed to parse transferred amount: ${message}`); } } }