@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.
- package/LICENSE.md +195 -0
- package/README.md +582 -0
- package/dist/BridgeMockController.d.ts +87 -0
- package/dist/BridgeMockController.d.ts.map +1 -0
- package/dist/BridgeMockController.js +300 -0
- package/dist/BridgeMockController.js.map +1 -0
- package/dist/KPICollector.d.ts +81 -0
- package/dist/KPICollector.d.ts.map +1 -0
- package/dist/KPICollector.js +239 -0
- package/dist/KPICollector.js.map +1 -0
- package/dist/MessageTracker.d.ts +82 -0
- package/dist/MessageTracker.d.ts.map +1 -0
- package/dist/MessageTracker.js +213 -0
- package/dist/MessageTracker.js.map +1 -0
- package/dist/RebalancerSimulationHarness.d.ts +72 -0
- package/dist/RebalancerSimulationHarness.d.ts.map +1 -0
- package/dist/RebalancerSimulationHarness.js +217 -0
- package/dist/RebalancerSimulationHarness.js.map +1 -0
- package/dist/ScenarioGenerator.d.ts +50 -0
- package/dist/ScenarioGenerator.d.ts.map +1 -0
- package/dist/ScenarioGenerator.js +326 -0
- package/dist/ScenarioGenerator.js.map +1 -0
- package/dist/ScenarioLoader.d.ts +18 -0
- package/dist/ScenarioLoader.d.ts.map +1 -0
- package/dist/ScenarioLoader.js +59 -0
- package/dist/ScenarioLoader.js.map +1 -0
- package/dist/SimulationDeployment.d.ts +20 -0
- package/dist/SimulationDeployment.d.ts.map +1 -0
- package/dist/SimulationDeployment.js +170 -0
- package/dist/SimulationDeployment.js.map +1 -0
- package/dist/SimulationEngine.d.ts +58 -0
- package/dist/SimulationEngine.d.ts.map +1 -0
- package/dist/SimulationEngine.js +302 -0
- package/dist/SimulationEngine.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/runners/NoOpRebalancer.d.ts +17 -0
- package/dist/runners/NoOpRebalancer.d.ts.map +1 -0
- package/dist/runners/NoOpRebalancer.js +28 -0
- package/dist/runners/NoOpRebalancer.js.map +1 -0
- package/dist/runners/ProductionRebalancerRunner.d.ts +22 -0
- package/dist/runners/ProductionRebalancerRunner.d.ts.map +1 -0
- package/dist/runners/ProductionRebalancerRunner.js +219 -0
- package/dist/runners/ProductionRebalancerRunner.js.map +1 -0
- package/dist/runners/SimpleRunner.d.ts +31 -0
- package/dist/runners/SimpleRunner.d.ts.map +1 -0
- package/dist/runners/SimpleRunner.js +286 -0
- package/dist/runners/SimpleRunner.js.map +1 -0
- package/dist/runners/SimulationRegistry.d.ts +46 -0
- package/dist/runners/SimulationRegistry.d.ts.map +1 -0
- package/dist/runners/SimulationRegistry.js +156 -0
- package/dist/runners/SimulationRegistry.js.map +1 -0
- package/dist/types.d.ts +637 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +158 -0
- package/dist/types.js.map +1 -0
- package/dist/visualizer/HtmlTimelineGenerator.d.ts +6 -0
- package/dist/visualizer/HtmlTimelineGenerator.d.ts.map +1 -0
- package/dist/visualizer/HtmlTimelineGenerator.js +1321 -0
- package/dist/visualizer/HtmlTimelineGenerator.js.map +1 -0
- package/dist/visualizer/index.d.ts +4 -0
- package/dist/visualizer/index.d.ts.map +1 -0
- package/dist/visualizer/index.js +3 -0
- package/dist/visualizer/index.js.map +1 -0
- package/package.json +62 -0
- package/src/BridgeMockController.ts +404 -0
- package/src/KPICollector.ts +304 -0
- package/src/MessageTracker.ts +312 -0
- package/src/RebalancerSimulationHarness.ts +325 -0
- package/src/ScenarioGenerator.ts +433 -0
- package/src/ScenarioLoader.ts +73 -0
- package/src/SimulationDeployment.ts +265 -0
- package/src/SimulationEngine.ts +432 -0
- package/src/index.ts +101 -0
- package/src/runners/NoOpRebalancer.ts +40 -0
- package/src/runners/ProductionRebalancerRunner.ts +289 -0
- package/src/runners/SimpleRunner.ts +382 -0
- package/src/runners/SimulationRegistry.ts +215 -0
- package/src/types.ts +878 -0
- package/src/visualizer/HtmlTimelineGenerator.ts +1341 -0
- 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
|
+
}
|