@hyperlane-xyz/rebalancer-sim 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE.md +195 -0
  2. package/README.md +582 -0
  3. package/dist/BridgeMockController.d.ts +87 -0
  4. package/dist/BridgeMockController.d.ts.map +1 -0
  5. package/dist/BridgeMockController.js +300 -0
  6. package/dist/BridgeMockController.js.map +1 -0
  7. package/dist/KPICollector.d.ts +81 -0
  8. package/dist/KPICollector.d.ts.map +1 -0
  9. package/dist/KPICollector.js +239 -0
  10. package/dist/KPICollector.js.map +1 -0
  11. package/dist/MessageTracker.d.ts +82 -0
  12. package/dist/MessageTracker.d.ts.map +1 -0
  13. package/dist/MessageTracker.js +213 -0
  14. package/dist/MessageTracker.js.map +1 -0
  15. package/dist/RebalancerSimulationHarness.d.ts +72 -0
  16. package/dist/RebalancerSimulationHarness.d.ts.map +1 -0
  17. package/dist/RebalancerSimulationHarness.js +217 -0
  18. package/dist/RebalancerSimulationHarness.js.map +1 -0
  19. package/dist/ScenarioGenerator.d.ts +50 -0
  20. package/dist/ScenarioGenerator.d.ts.map +1 -0
  21. package/dist/ScenarioGenerator.js +326 -0
  22. package/dist/ScenarioGenerator.js.map +1 -0
  23. package/dist/ScenarioLoader.d.ts +18 -0
  24. package/dist/ScenarioLoader.d.ts.map +1 -0
  25. package/dist/ScenarioLoader.js +59 -0
  26. package/dist/ScenarioLoader.js.map +1 -0
  27. package/dist/SimulationDeployment.d.ts +20 -0
  28. package/dist/SimulationDeployment.d.ts.map +1 -0
  29. package/dist/SimulationDeployment.js +170 -0
  30. package/dist/SimulationDeployment.js.map +1 -0
  31. package/dist/SimulationEngine.d.ts +58 -0
  32. package/dist/SimulationEngine.d.ts.map +1 -0
  33. package/dist/SimulationEngine.js +302 -0
  34. package/dist/SimulationEngine.js.map +1 -0
  35. package/dist/index.d.ts +22 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +26 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/runners/NoOpRebalancer.d.ts +17 -0
  40. package/dist/runners/NoOpRebalancer.d.ts.map +1 -0
  41. package/dist/runners/NoOpRebalancer.js +28 -0
  42. package/dist/runners/NoOpRebalancer.js.map +1 -0
  43. package/dist/runners/ProductionRebalancerRunner.d.ts +22 -0
  44. package/dist/runners/ProductionRebalancerRunner.d.ts.map +1 -0
  45. package/dist/runners/ProductionRebalancerRunner.js +219 -0
  46. package/dist/runners/ProductionRebalancerRunner.js.map +1 -0
  47. package/dist/runners/SimpleRunner.d.ts +31 -0
  48. package/dist/runners/SimpleRunner.d.ts.map +1 -0
  49. package/dist/runners/SimpleRunner.js +286 -0
  50. package/dist/runners/SimpleRunner.js.map +1 -0
  51. package/dist/runners/SimulationRegistry.d.ts +46 -0
  52. package/dist/runners/SimulationRegistry.d.ts.map +1 -0
  53. package/dist/runners/SimulationRegistry.js +156 -0
  54. package/dist/runners/SimulationRegistry.js.map +1 -0
  55. package/dist/types.d.ts +637 -0
  56. package/dist/types.d.ts.map +1 -0
  57. package/dist/types.js +158 -0
  58. package/dist/types.js.map +1 -0
  59. package/dist/visualizer/HtmlTimelineGenerator.d.ts +6 -0
  60. package/dist/visualizer/HtmlTimelineGenerator.d.ts.map +1 -0
  61. package/dist/visualizer/HtmlTimelineGenerator.js +1321 -0
  62. package/dist/visualizer/HtmlTimelineGenerator.js.map +1 -0
  63. package/dist/visualizer/index.d.ts +4 -0
  64. package/dist/visualizer/index.d.ts.map +1 -0
  65. package/dist/visualizer/index.js +3 -0
  66. package/dist/visualizer/index.js.map +1 -0
  67. package/package.json +62 -0
  68. package/src/BridgeMockController.ts +404 -0
  69. package/src/KPICollector.ts +304 -0
  70. package/src/MessageTracker.ts +312 -0
  71. package/src/RebalancerSimulationHarness.ts +325 -0
  72. package/src/ScenarioGenerator.ts +433 -0
  73. package/src/ScenarioLoader.ts +73 -0
  74. package/src/SimulationDeployment.ts +265 -0
  75. package/src/SimulationEngine.ts +432 -0
  76. package/src/index.ts +101 -0
  77. package/src/runners/NoOpRebalancer.ts +40 -0
  78. package/src/runners/ProductionRebalancerRunner.ts +289 -0
  79. package/src/runners/SimpleRunner.ts +382 -0
  80. package/src/runners/SimulationRegistry.ts +215 -0
  81. package/src/types.ts +878 -0
  82. package/src/visualizer/HtmlTimelineGenerator.ts +1341 -0
  83. package/src/visualizer/index.ts +7 -0
@@ -0,0 +1,304 @@
1
+ import type { ethers } from 'ethers';
2
+
3
+ import { ERC20Test__factory } from '@hyperlane-xyz/core';
4
+
5
+ import type {
6
+ ChainMetrics,
7
+ DeployedDomain,
8
+ RebalanceRecord,
9
+ SimulationKPIs,
10
+ TransferRecord,
11
+ } from './types.js';
12
+
13
+ /**
14
+ * KPICollector tracks metrics throughout a simulation run.
15
+ */
16
+ export class KPICollector {
17
+ private transferRecords: Map<string, TransferRecord> = new Map();
18
+ private rebalanceRecords: Map<string, RebalanceRecord> = new Map();
19
+ /** Maps bridge transfer ID to rebalance ID for correlation */
20
+ private bridgeToRebalanceMap: Map<string, string> = new Map();
21
+ private initialBalances: Record<string, bigint> = {};
22
+
23
+ constructor(
24
+ private readonly provider: ethers.providers.JsonRpcProvider,
25
+ private readonly domains: Record<string, DeployedDomain>,
26
+ ) {}
27
+
28
+ /**
29
+ * Initialize with initial balances (passed explicitly or fetched)
30
+ */
31
+ async initialize(initialBalances?: Record<string, bigint>): Promise<void> {
32
+ if (initialBalances) {
33
+ this.initialBalances = { ...initialBalances };
34
+ } else {
35
+ for (const chainName of Object.keys(this.domains)) {
36
+ this.initialBalances[chainName] = await this.getBalance(chainName);
37
+ }
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Get current balance for a chain's warp token
43
+ */
44
+ private async getBalance(chainName: string): Promise<bigint> {
45
+ const domain = this.domains[chainName];
46
+ const token = ERC20Test__factory.connect(
47
+ domain.collateralToken,
48
+ this.provider,
49
+ );
50
+ const balance = await token.balanceOf(domain.warpToken);
51
+ return balance.toBigInt();
52
+ }
53
+
54
+ /**
55
+ * Record transfer start
56
+ */
57
+ recordTransferStart(
58
+ id: string,
59
+ origin: string,
60
+ destination: string,
61
+ amount: bigint,
62
+ ): void {
63
+ this.transferRecords.set(id, {
64
+ id,
65
+ origin,
66
+ destination,
67
+ amount,
68
+ startTime: Date.now(),
69
+ status: 'pending',
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Record transfer completion
75
+ */
76
+ recordTransferComplete(id: string): void {
77
+ const record = this.transferRecords.get(id);
78
+ if (record) {
79
+ record.endTime = Date.now();
80
+ record.latency = record.endTime - record.startTime;
81
+ record.status = 'completed';
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Record transfer failure
87
+ */
88
+ recordTransferFailed(id: string): void {
89
+ const record = this.transferRecords.get(id);
90
+ if (record) {
91
+ record.endTime = Date.now();
92
+ record.status = 'failed';
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Mark all pending transfers as complete (used after mailbox processing)
98
+ */
99
+ markAllPendingAsComplete(): void {
100
+ const now = Date.now();
101
+ for (const record of this.transferRecords.values()) {
102
+ if (record.status === 'pending') {
103
+ record.endTime = now;
104
+ record.latency = now - record.startTime;
105
+ record.status = 'completed';
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Record a rebalance operation start (when SentTransferRemote fires)
112
+ * Returns the rebalance ID for correlation
113
+ */
114
+ recordRebalanceStart(
115
+ origin: string,
116
+ destination: string,
117
+ amount: bigint,
118
+ gasCost: bigint,
119
+ ): string {
120
+ const id = `rebalance-${this.rebalanceRecords.size}`;
121
+ this.rebalanceRecords.set(id, {
122
+ id,
123
+ origin,
124
+ destination,
125
+ amount,
126
+ startTime: Date.now(),
127
+ gasCost,
128
+ status: 'pending',
129
+ });
130
+ return id;
131
+ }
132
+
133
+ /**
134
+ * Link a bridge transfer ID to a rebalance ID for delivery tracking
135
+ */
136
+ linkBridgeTransfer(bridgeTransferId: string, rebalanceId: string): void {
137
+ this.bridgeToRebalanceMap.set(bridgeTransferId, rebalanceId);
138
+ const record = this.rebalanceRecords.get(rebalanceId);
139
+ if (record) {
140
+ record.bridgeTransferId = bridgeTransferId;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Record rebalance completion (when bridge delivers)
146
+ */
147
+ recordRebalanceComplete(bridgeTransferId: string): void {
148
+ const rebalanceId = this.bridgeToRebalanceMap.get(bridgeTransferId);
149
+ if (!rebalanceId) return;
150
+
151
+ const record = this.rebalanceRecords.get(rebalanceId);
152
+ if (record && record.status === 'pending') {
153
+ record.endTime = Date.now();
154
+ record.latency = record.endTime - record.startTime;
155
+ record.status = 'completed';
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Record rebalance failure
161
+ */
162
+ recordRebalanceFailed(bridgeTransferId: string): void {
163
+ const rebalanceId = this.bridgeToRebalanceMap.get(bridgeTransferId);
164
+ if (!rebalanceId) return;
165
+
166
+ const record = this.rebalanceRecords.get(rebalanceId);
167
+ if (record) {
168
+ record.endTime = Date.now();
169
+ record.status = 'failed';
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Get pending rebalances count
175
+ */
176
+ getPendingRebalancesCount(): number {
177
+ return Array.from(this.rebalanceRecords.values()).filter(
178
+ (r) => r.status === 'pending',
179
+ ).length;
180
+ }
181
+
182
+ /**
183
+ * Calculate percentile from sorted array
184
+ */
185
+ private percentile(sorted: number[], p: number): number {
186
+ if (sorted.length === 0) return 0;
187
+ const index = Math.ceil((p / 100) * sorted.length) - 1;
188
+ return sorted[Math.max(0, index)];
189
+ }
190
+
191
+ /**
192
+ * Generate final KPIs
193
+ */
194
+ async generateKPIs(): Promise<SimulationKPIs> {
195
+ const transfers = Array.from(this.transferRecords.values());
196
+ const completed = transfers.filter((t) => t.status === 'completed');
197
+ const failed = transfers.filter((t) => t.status === 'failed');
198
+
199
+ // Calculate latencies
200
+ const latencies = completed
201
+ .filter((t) => t.latency !== undefined)
202
+ .map((t) => t.latency!)
203
+ .sort((a, b) => a - b);
204
+
205
+ const avgLatency =
206
+ latencies.length > 0
207
+ ? latencies.reduce((a, b) => a + b, 0) / latencies.length
208
+ : 0;
209
+
210
+ // Calculate per-chain metrics
211
+ const perChainMetrics: Record<string, ChainMetrics> = {};
212
+ for (const chainName of Object.keys(this.domains)) {
213
+ const transfersIn = transfers.filter(
214
+ (t) => t.destination === chainName && t.status === 'completed',
215
+ ).length;
216
+ const transfersOut = transfers.filter(
217
+ (t) => t.origin === chainName && t.status === 'completed',
218
+ ).length;
219
+
220
+ const allRebalances = Array.from(this.rebalanceRecords.values());
221
+ const rebalancesIn = allRebalances.filter(
222
+ (r) => r.destination === chainName && r.status === 'completed',
223
+ ).length;
224
+ const rebalancesOut = allRebalances.filter(
225
+ (r) => r.origin === chainName && r.status === 'completed',
226
+ ).length;
227
+
228
+ const rebalanceVolumeIn = allRebalances
229
+ .filter((r) => r.destination === chainName && r.status === 'completed')
230
+ .reduce((sum, r) => sum + r.amount, BigInt(0));
231
+ const rebalanceVolumeOut = allRebalances
232
+ .filter((r) => r.origin === chainName && r.status === 'completed')
233
+ .reduce((sum, r) => sum + r.amount, BigInt(0));
234
+
235
+ const finalBalance = await this.getBalance(chainName);
236
+
237
+ perChainMetrics[chainName] = {
238
+ chainName,
239
+ initialBalance: this.initialBalances[chainName] ?? BigInt(0),
240
+ finalBalance,
241
+ transfersIn,
242
+ transfersOut,
243
+ rebalancesIn,
244
+ rebalancesOut,
245
+ rebalanceVolumeIn,
246
+ rebalanceVolumeOut,
247
+ };
248
+ }
249
+
250
+ // Calculate rebalance totals
251
+ const allRebalanceRecords = Array.from(this.rebalanceRecords.values());
252
+ const completedRebalances = allRebalanceRecords.filter(
253
+ (r) => r.status === 'completed',
254
+ );
255
+ const totalRebalanceVolume = completedRebalances.reduce(
256
+ (sum, r) => sum + r.amount,
257
+ BigInt(0),
258
+ );
259
+ const totalGasCost = completedRebalances.reduce(
260
+ (sum, r) => sum + r.gasCost,
261
+ BigInt(0),
262
+ );
263
+
264
+ return {
265
+ totalTransfers: transfers.length,
266
+ completedTransfers: completed.length,
267
+ failedTransfers: failed.length,
268
+ completionRate:
269
+ transfers.length > 0 ? completed.length / transfers.length : 1,
270
+ averageLatency: avgLatency,
271
+ p50Latency: this.percentile(latencies, 50),
272
+ p95Latency: this.percentile(latencies, 95),
273
+ p99Latency: this.percentile(latencies, 99),
274
+ totalRebalances: completedRebalances.length,
275
+ rebalanceVolume: totalRebalanceVolume,
276
+ totalGasCost,
277
+ perChainMetrics,
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Get transfer records
283
+ */
284
+ getTransferRecords(): TransferRecord[] {
285
+ return Array.from(this.transferRecords.values());
286
+ }
287
+
288
+ /**
289
+ * Get rebalance records
290
+ */
291
+ getRebalanceRecords(): RebalanceRecord[] {
292
+ return Array.from(this.rebalanceRecords.values());
293
+ }
294
+
295
+ /**
296
+ * Reset collector for new simulation
297
+ */
298
+ reset(): void {
299
+ this.transferRecords.clear();
300
+ this.rebalanceRecords.clear();
301
+ this.bridgeToRebalanceMap.clear();
302
+ this.initialBalances = {};
303
+ }
304
+ }
@@ -0,0 +1,312 @@
1
+ import { ethers } from 'ethers';
2
+ import { EventEmitter } from 'events';
3
+
4
+ import { MockMailbox__factory } from '@hyperlane-xyz/core';
5
+ import { rootLogger } from '@hyperlane-xyz/utils';
6
+
7
+ import type { DeployedDomain } from './types.js';
8
+
9
+ const logger = rootLogger.child({ module: 'MessageTracker' });
10
+
11
+ /**
12
+ * Tracked message for off-chain processing control
13
+ */
14
+ export interface TrackedMessage {
15
+ id: string;
16
+ transferId: string;
17
+ origin: string;
18
+ destination: string;
19
+ /** Nonce on the destination mailbox */
20
+ destinationNonce: number;
21
+ /** When the message was dispatched */
22
+ dispatchedAt: number;
23
+ /** When we should attempt delivery */
24
+ deliveryTime: number;
25
+ /** Processing status */
26
+ status: 'pending' | 'inflight' | 'delivered' | 'failed';
27
+ /** Number of delivery attempts */
28
+ attempts: number;
29
+ /** Last error if failed */
30
+ lastError?: string;
31
+ }
32
+
33
+ /**
34
+ * MessageTracker provides off-chain tracking and selective processing
35
+ * of Hyperlane messages. Fires transactions in parallel without blocking
36
+ * on receipts, similar to how the Hyperlane relayer batches messages.
37
+ */
38
+ export class MessageTracker extends EventEmitter {
39
+ private messages: Map<string, TrackedMessage> = new Map();
40
+ private messageCounter = 0;
41
+ private destinationNonces: Map<string, number> = new Map();
42
+ private signer: ethers.Wallet;
43
+ private currentNonce: number = 0;
44
+ private nonceInitialized = false;
45
+
46
+ constructor(
47
+ private readonly provider: ethers.providers.JsonRpcProvider,
48
+ private readonly domains: Record<string, DeployedDomain>,
49
+ signerKey: string,
50
+ ) {
51
+ super();
52
+ this.signer = new ethers.Wallet(signerKey, provider);
53
+ }
54
+
55
+ /**
56
+ * Initialize by fetching current nonces from all destination mailboxes
57
+ */
58
+ async initialize(): Promise<void> {
59
+ for (const [chainName, domain] of Object.entries(this.domains)) {
60
+ const mailbox = MockMailbox__factory.connect(
61
+ domain.mailbox,
62
+ this.provider,
63
+ );
64
+ const nonce = await mailbox.inboundUnprocessedNonce();
65
+ this.destinationNonces.set(chainName, Number(nonce));
66
+ }
67
+ // Initialize signer nonce for parallel tx submission
68
+ this.currentNonce = await this.signer.getTransactionCount();
69
+ this.nonceInitialized = true;
70
+ }
71
+
72
+ /**
73
+ * Track a new message after a transfer is initiated.
74
+ * Call this after transferRemote() succeeds.
75
+ */
76
+ async trackMessage(
77
+ transferId: string,
78
+ origin: string,
79
+ destination: string,
80
+ deliveryDelay: number,
81
+ ): Promise<TrackedMessage> {
82
+ const destDomain = this.domains[destination];
83
+ const mailbox = MockMailbox__factory.connect(
84
+ destDomain.mailbox,
85
+ this.provider,
86
+ );
87
+ await mailbox.inboundUnprocessedNonce(); // Verify mailbox is accessible
88
+
89
+ const expectedNonce = this.destinationNonces.get(destination) || 0;
90
+ this.destinationNonces.set(destination, expectedNonce + 1);
91
+
92
+ const message: TrackedMessage = {
93
+ id: `msg-${this.messageCounter++}`,
94
+ transferId,
95
+ origin,
96
+ destination,
97
+ destinationNonce: expectedNonce,
98
+ dispatchedAt: Date.now(),
99
+ deliveryTime: Date.now() + deliveryDelay,
100
+ status: 'pending',
101
+ attempts: 0,
102
+ };
103
+
104
+ this.messages.set(message.id, message);
105
+ this.emit('message_tracked', message);
106
+
107
+ return message;
108
+ }
109
+
110
+ /**
111
+ * Get all messages ready for delivery (past their delivery time, not inflight)
112
+ */
113
+ getReadyMessages(): TrackedMessage[] {
114
+ const now = Date.now();
115
+ return Array.from(this.messages.values()).filter(
116
+ (m) => m.status === 'pending' && m.deliveryTime <= now,
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Get all pending messages (including not yet ready and inflight)
122
+ */
123
+ getPendingMessages(): TrackedMessage[] {
124
+ return Array.from(this.messages.values()).filter(
125
+ (m) => m.status === 'pending' || m.status === 'inflight',
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Process all ready messages in parallel without blocking on receipts.
131
+ * Fires transactions and subscribes to completion asynchronously.
132
+ */
133
+ async processReadyMessages(): Promise<{ delivered: number; failed: number }> {
134
+ const ready = this.getReadyMessages();
135
+ if (ready.length === 0) {
136
+ return { delivered: 0, failed: 0 };
137
+ }
138
+
139
+ // Ensure nonce is initialized
140
+ if (!this.nonceInitialized) {
141
+ this.currentNonce = await this.signer.getTransactionCount();
142
+ this.nonceInitialized = true;
143
+ }
144
+
145
+ // Check which messages can actually be processed (have sufficient liquidity)
146
+ // by doing a static call first
147
+ const processable: TrackedMessage[] = [];
148
+
149
+ const checkStartTime = Date.now();
150
+ for (const message of ready) {
151
+ const destDomain = this.domains[message.destination];
152
+ const mailbox = MockMailbox__factory.connect(
153
+ destDomain.mailbox,
154
+ this.signer,
155
+ );
156
+
157
+ const staticCallStart = Date.now();
158
+ try {
159
+ // Static call to check if it would succeed
160
+ await mailbox.callStatic.processInboundMessage(
161
+ message.destinationNonce,
162
+ );
163
+ const staticCallDuration = Date.now() - staticCallStart;
164
+ if (staticCallDuration > 100) {
165
+ logger.warn(
166
+ { transferId: message.transferId, staticCallDuration },
167
+ 'Slow static call',
168
+ );
169
+ }
170
+ processable.push(message);
171
+ // Log successful processing after retries
172
+ if (message.attempts > 0) {
173
+ const waitTime = Date.now() - message.dispatchedAt;
174
+ logger.debug(
175
+ {
176
+ transferId: message.transferId,
177
+ origin: message.origin,
178
+ destination: message.destination,
179
+ attempts: message.attempts,
180
+ waitTime,
181
+ },
182
+ 'Message ready after retries',
183
+ );
184
+ }
185
+ } catch (error: any) {
186
+ const staticCallDuration = Date.now() - staticCallStart;
187
+ const errorMsg = error.reason || error.message || '';
188
+ // Check if message was already delivered (e.g., by bridge controller)
189
+ // This is a permanent state, not a temporary error
190
+ if (errorMsg.includes('already delivered')) {
191
+ message.status = 'delivered';
192
+ this.emit('message_delivered', message);
193
+ continue;
194
+ }
195
+ // Other errors - mark attempt but keep pending for retry
196
+ message.attempts++;
197
+ message.lastError = errorMsg;
198
+
199
+ // Log failures - every 5 attempts or on slow static calls
200
+ if (message.attempts % 5 === 0 || staticCallDuration > 100) {
201
+ const waitTime = Date.now() - message.dispatchedAt;
202
+ logger.debug(
203
+ {
204
+ transferId: message.transferId,
205
+ origin: message.origin,
206
+ destination: message.destination,
207
+ attempts: message.attempts,
208
+ waitTime,
209
+ error: errorMsg,
210
+ },
211
+ 'Message delivery failed, will retry',
212
+ );
213
+ }
214
+ }
215
+ }
216
+
217
+ const totalCheckTime = Date.now() - checkStartTime;
218
+ if (totalCheckTime > 500) {
219
+ logger.warn(
220
+ { messageCount: ready.length, totalCheckTime },
221
+ 'Slow static call checks',
222
+ );
223
+ }
224
+
225
+ if (processable.length === 0) {
226
+ // No messages processable yet - not a failure, they will retry
227
+ return { delivered: 0, failed: 0 };
228
+ }
229
+
230
+ // Fire all processable transactions in parallel
231
+ const txPromises: Array<{
232
+ message: TrackedMessage;
233
+ txPromise: Promise<ethers.ContractTransaction>;
234
+ }> = [];
235
+
236
+ for (const message of processable) {
237
+ message.status = 'inflight';
238
+ message.attempts++;
239
+
240
+ const destDomain = this.domains[message.destination];
241
+ const mailbox = MockMailbox__factory.connect(
242
+ destDomain.mailbox,
243
+ this.signer,
244
+ );
245
+
246
+ // Fire transaction with explicit nonce (don't wait)
247
+ const txPromise = mailbox.processInboundMessage(
248
+ message.destinationNonce,
249
+ { nonce: this.currentNonce++ },
250
+ );
251
+
252
+ txPromises.push({ message, txPromise });
253
+ }
254
+
255
+ // Subscribe to all tx completions asynchronously
256
+ let delivered = 0;
257
+ let failed = 0;
258
+
259
+ await Promise.all(
260
+ txPromises.map(async ({ message, txPromise }) => {
261
+ try {
262
+ const tx = await txPromise;
263
+ await tx.wait();
264
+
265
+ message.status = 'delivered';
266
+ this.emit('message_delivered', message);
267
+ delivered++;
268
+ } catch (error: any) {
269
+ // Transaction failed - back to pending for retry
270
+ message.status = 'pending';
271
+ message.lastError = error.reason || error.message;
272
+ failed++;
273
+ }
274
+ }),
275
+ );
276
+
277
+ return { delivered, failed };
278
+ }
279
+
280
+ /**
281
+ * Check if there are any pending or inflight messages
282
+ */
283
+ hasPendingMessages(): boolean {
284
+ return this.getPendingMessages().length > 0;
285
+ }
286
+
287
+ /**
288
+ * Get message by transfer ID
289
+ */
290
+ getMessageByTransferId(transferId: string): TrackedMessage | undefined {
291
+ return Array.from(this.messages.values()).find(
292
+ (m) => m.transferId === transferId,
293
+ );
294
+ }
295
+
296
+ /**
297
+ * Get all messages
298
+ */
299
+ getAllMessages(): TrackedMessage[] {
300
+ return Array.from(this.messages.values());
301
+ }
302
+
303
+ /**
304
+ * Clear all tracked messages (for reset)
305
+ */
306
+ clear(): void {
307
+ this.messages.clear();
308
+ this.messageCounter = 0;
309
+ this.destinationNonces.clear();
310
+ this.nonceInitialized = false;
311
+ }
312
+ }