@hyperlane-xyz/rebalancer 0.1.0-beta.5a8bd28ab

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 (202) hide show
  1. package/README.md +178 -0
  2. package/dist/config/RebalancerConfig.d.ts +12 -0
  3. package/dist/config/RebalancerConfig.d.ts.map +1 -0
  4. package/dist/config/RebalancerConfig.js +29 -0
  5. package/dist/config/RebalancerConfig.js.map +1 -0
  6. package/dist/config/RebalancerConfig.test.d.ts +2 -0
  7. package/dist/config/RebalancerConfig.test.d.ts.map +1 -0
  8. package/dist/config/RebalancerConfig.test.js +325 -0
  9. package/dist/config/RebalancerConfig.test.js.map +1 -0
  10. package/dist/core/Rebalancer.d.ts +23 -0
  11. package/dist/core/Rebalancer.d.ts.map +1 -0
  12. package/dist/core/Rebalancer.js +290 -0
  13. package/dist/core/Rebalancer.js.map +1 -0
  14. package/dist/core/RebalancerService.d.ts +115 -0
  15. package/dist/core/RebalancerService.d.ts.map +1 -0
  16. package/dist/core/RebalancerService.js +227 -0
  17. package/dist/core/RebalancerService.js.map +1 -0
  18. package/dist/core/WithInflightGuard.d.ts +20 -0
  19. package/dist/core/WithInflightGuard.d.ts.map +1 -0
  20. package/dist/core/WithInflightGuard.js +47 -0
  21. package/dist/core/WithInflightGuard.js.map +1 -0
  22. package/dist/core/WithInflightGuard.test.d.ts +2 -0
  23. package/dist/core/WithInflightGuard.test.d.ts.map +1 -0
  24. package/dist/core/WithInflightGuard.test.js +64 -0
  25. package/dist/core/WithInflightGuard.test.js.map +1 -0
  26. package/dist/core/WithSemaphore.d.ts +22 -0
  27. package/dist/core/WithSemaphore.d.ts.map +1 -0
  28. package/dist/core/WithSemaphore.js +67 -0
  29. package/dist/core/WithSemaphore.js.map +1 -0
  30. package/dist/core/WithSemaphore.test.d.ts +2 -0
  31. package/dist/core/WithSemaphore.test.d.ts.map +1 -0
  32. package/dist/core/WithSemaphore.test.js +83 -0
  33. package/dist/core/WithSemaphore.test.js.map +1 -0
  34. package/dist/factories/RebalancerContextFactory.d.ts +41 -0
  35. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -0
  36. package/dist/factories/RebalancerContextFactory.js +115 -0
  37. package/dist/factories/RebalancerContextFactory.js.map +1 -0
  38. package/dist/index.d.ts +33 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +35 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/interfaces/IMetrics.d.ts +5 -0
  43. package/dist/interfaces/IMetrics.d.ts.map +1 -0
  44. package/dist/interfaces/IMetrics.js +2 -0
  45. package/dist/interfaces/IMetrics.js.map +1 -0
  46. package/dist/interfaces/IMonitor.d.ts +51 -0
  47. package/dist/interfaces/IMonitor.d.ts.map +1 -0
  48. package/dist/interfaces/IMonitor.js +14 -0
  49. package/dist/interfaces/IMonitor.js.map +1 -0
  50. package/dist/interfaces/IRebalancer.d.ts +15 -0
  51. package/dist/interfaces/IRebalancer.d.ts.map +1 -0
  52. package/dist/interfaces/IRebalancer.js +2 -0
  53. package/dist/interfaces/IRebalancer.js.map +1 -0
  54. package/dist/interfaces/IStrategy.d.ts +11 -0
  55. package/dist/interfaces/IStrategy.d.ts.map +1 -0
  56. package/dist/interfaces/IStrategy.js +2 -0
  57. package/dist/interfaces/IStrategy.js.map +1 -0
  58. package/dist/metrics/Metrics.d.ts +31 -0
  59. package/dist/metrics/Metrics.d.ts.map +1 -0
  60. package/dist/metrics/Metrics.js +302 -0
  61. package/dist/metrics/Metrics.js.map +1 -0
  62. package/dist/metrics/PriceGetter.d.ts +10 -0
  63. package/dist/metrics/PriceGetter.d.ts.map +1 -0
  64. package/dist/metrics/PriceGetter.js +41 -0
  65. package/dist/metrics/PriceGetter.js.map +1 -0
  66. package/dist/metrics/scripts/metrics.d.ts +14 -0
  67. package/dist/metrics/scripts/metrics.d.ts.map +1 -0
  68. package/dist/metrics/scripts/metrics.js +198 -0
  69. package/dist/metrics/scripts/metrics.js.map +1 -0
  70. package/dist/metrics/types.d.ts +24 -0
  71. package/dist/metrics/types.d.ts.map +1 -0
  72. package/dist/metrics/types.js +2 -0
  73. package/dist/metrics/types.js.map +1 -0
  74. package/dist/metrics/utils/metrics.d.ts +12 -0
  75. package/dist/metrics/utils/metrics.d.ts.map +1 -0
  76. package/dist/metrics/utils/metrics.js +28 -0
  77. package/dist/metrics/utils/metrics.js.map +1 -0
  78. package/dist/monitor/Monitor.d.ts +26 -0
  79. package/dist/monitor/Monitor.d.ts.map +1 -0
  80. package/dist/monitor/Monitor.js +116 -0
  81. package/dist/monitor/Monitor.js.map +1 -0
  82. package/dist/service.d.ts +3 -0
  83. package/dist/service.d.ts.map +1 -0
  84. package/dist/service.js +125 -0
  85. package/dist/service.js.map +1 -0
  86. package/dist/strategy/BaseStrategy.d.ts +34 -0
  87. package/dist/strategy/BaseStrategy.d.ts.map +1 -0
  88. package/dist/strategy/BaseStrategy.js +127 -0
  89. package/dist/strategy/BaseStrategy.js.map +1 -0
  90. package/dist/strategy/MinAmountStrategy.d.ts +27 -0
  91. package/dist/strategy/MinAmountStrategy.d.ts.map +1 -0
  92. package/dist/strategy/MinAmountStrategy.js +103 -0
  93. package/dist/strategy/MinAmountStrategy.js.map +1 -0
  94. package/dist/strategy/MinAmountStrategy.test.d.ts +2 -0
  95. package/dist/strategy/MinAmountStrategy.test.d.ts.map +1 -0
  96. package/dist/strategy/MinAmountStrategy.test.js +472 -0
  97. package/dist/strategy/MinAmountStrategy.test.js.map +1 -0
  98. package/dist/strategy/StrategyFactory.d.ts +16 -0
  99. package/dist/strategy/StrategyFactory.d.ts.map +1 -0
  100. package/dist/strategy/StrategyFactory.js +25 -0
  101. package/dist/strategy/StrategyFactory.js.map +1 -0
  102. package/dist/strategy/StrategyFactory.test.d.ts +2 -0
  103. package/dist/strategy/StrategyFactory.test.d.ts.map +1 -0
  104. package/dist/strategy/StrategyFactory.test.js +80 -0
  105. package/dist/strategy/StrategyFactory.test.js.map +1 -0
  106. package/dist/strategy/WeightedStrategy.d.ts +23 -0
  107. package/dist/strategy/WeightedStrategy.d.ts.map +1 -0
  108. package/dist/strategy/WeightedStrategy.js +61 -0
  109. package/dist/strategy/WeightedStrategy.js.map +1 -0
  110. package/dist/strategy/WeightedStrategy.test.d.ts +2 -0
  111. package/dist/strategy/WeightedStrategy.test.d.ts.map +1 -0
  112. package/dist/strategy/WeightedStrategy.test.js +307 -0
  113. package/dist/strategy/WeightedStrategy.test.js.map +1 -0
  114. package/dist/strategy/index.d.ts +5 -0
  115. package/dist/strategy/index.d.ts.map +1 -0
  116. package/dist/strategy/index.js +5 -0
  117. package/dist/strategy/index.js.map +1 -0
  118. package/dist/test/helpers.d.ts +8 -0
  119. package/dist/test/helpers.d.ts.map +1 -0
  120. package/dist/test/helpers.js +33 -0
  121. package/dist/test/helpers.js.map +1 -0
  122. package/dist/utils/ExplorerClient.d.ts +14 -0
  123. package/dist/utils/ExplorerClient.d.ts.map +1 -0
  124. package/dist/utils/ExplorerClient.js +82 -0
  125. package/dist/utils/ExplorerClient.js.map +1 -0
  126. package/dist/utils/balanceUtils.d.ts +13 -0
  127. package/dist/utils/balanceUtils.d.ts.map +1 -0
  128. package/dist/utils/balanceUtils.js +43 -0
  129. package/dist/utils/balanceUtils.js.map +1 -0
  130. package/dist/utils/balanceUtils.test.d.ts +2 -0
  131. package/dist/utils/balanceUtils.test.d.ts.map +1 -0
  132. package/dist/utils/balanceUtils.test.js +54 -0
  133. package/dist/utils/balanceUtils.test.js.map +1 -0
  134. package/dist/utils/bridgeUtils.d.ts +19 -0
  135. package/dist/utils/bridgeUtils.d.ts.map +1 -0
  136. package/dist/utils/bridgeUtils.js +20 -0
  137. package/dist/utils/bridgeUtils.js.map +1 -0
  138. package/dist/utils/bridgeUtils.test.d.ts +2 -0
  139. package/dist/utils/bridgeUtils.test.d.ts.map +1 -0
  140. package/dist/utils/bridgeUtils.test.js +77 -0
  141. package/dist/utils/bridgeUtils.test.js.map +1 -0
  142. package/dist/utils/errors.d.ts +4 -0
  143. package/dist/utils/errors.d.ts.map +1 -0
  144. package/dist/utils/errors.js +6 -0
  145. package/dist/utils/errors.js.map +1 -0
  146. package/dist/utils/files.d.ts +35 -0
  147. package/dist/utils/files.d.ts.map +1 -0
  148. package/dist/utils/files.js +190 -0
  149. package/dist/utils/files.js.map +1 -0
  150. package/dist/utils/generalUtils.d.ts +3 -0
  151. package/dist/utils/generalUtils.d.ts.map +1 -0
  152. package/dist/utils/generalUtils.js +9 -0
  153. package/dist/utils/generalUtils.js.map +1 -0
  154. package/dist/utils/index.d.ts +5 -0
  155. package/dist/utils/index.d.ts.map +1 -0
  156. package/dist/utils/index.js +5 -0
  157. package/dist/utils/index.js.map +1 -0
  158. package/dist/utils/tokenUtils.d.ts +14 -0
  159. package/dist/utils/tokenUtils.d.ts.map +1 -0
  160. package/dist/utils/tokenUtils.js +21 -0
  161. package/dist/utils/tokenUtils.js.map +1 -0
  162. package/package.json +70 -0
  163. package/src/config/RebalancerConfig.test.ts +388 -0
  164. package/src/config/RebalancerConfig.ts +39 -0
  165. package/src/core/Rebalancer.ts +471 -0
  166. package/src/core/RebalancerService.ts +333 -0
  167. package/src/core/WithInflightGuard.test.ts +131 -0
  168. package/src/core/WithInflightGuard.ts +67 -0
  169. package/src/core/WithSemaphore.test.ts +112 -0
  170. package/src/core/WithSemaphore.ts +92 -0
  171. package/src/factories/RebalancerContextFactory.ts +210 -0
  172. package/src/index.ts +68 -0
  173. package/src/interfaces/IMetrics.ts +5 -0
  174. package/src/interfaces/IMonitor.ts +63 -0
  175. package/src/interfaces/IRebalancer.ts +20 -0
  176. package/src/interfaces/IStrategy.ts +13 -0
  177. package/src/metrics/Metrics.ts +558 -0
  178. package/src/metrics/PriceGetter.ts +74 -0
  179. package/src/metrics/scripts/metrics.ts +298 -0
  180. package/src/metrics/types.ts +27 -0
  181. package/src/metrics/utils/metrics.ts +33 -0
  182. package/src/monitor/Monitor.ts +174 -0
  183. package/src/service.ts +154 -0
  184. package/src/strategy/BaseStrategy.ts +210 -0
  185. package/src/strategy/MinAmountStrategy.test.ts +625 -0
  186. package/src/strategy/MinAmountStrategy.ts +170 -0
  187. package/src/strategy/StrategyFactory.test.ts +109 -0
  188. package/src/strategy/StrategyFactory.ts +48 -0
  189. package/src/strategy/WeightedStrategy.test.ts +408 -0
  190. package/src/strategy/WeightedStrategy.ts +93 -0
  191. package/src/strategy/index.ts +4 -0
  192. package/src/test/helpers.ts +46 -0
  193. package/src/utils/ExplorerClient.ts +99 -0
  194. package/src/utils/balanceUtils.test.ts +74 -0
  195. package/src/utils/balanceUtils.ts +69 -0
  196. package/src/utils/bridgeUtils.test.ts +92 -0
  197. package/src/utils/bridgeUtils.ts +42 -0
  198. package/src/utils/errors.ts +5 -0
  199. package/src/utils/files.ts +276 -0
  200. package/src/utils/generalUtils.ts +13 -0
  201. package/src/utils/index.ts +4 -0
  202. package/src/utils/tokenUtils.ts +26 -0
@@ -0,0 +1,333 @@
1
+ import { Logger } from 'pino';
2
+
3
+ import { IRegistry } from '@hyperlane-xyz/registry';
4
+ import {
5
+ type MultiProtocolProvider,
6
+ type MultiProvider,
7
+ Token,
8
+ } from '@hyperlane-xyz/sdk';
9
+ import { assert, toWei } from '@hyperlane-xyz/utils';
10
+
11
+ import { RebalancerConfig } from '../config/RebalancerConfig.js';
12
+ import { RebalancerContextFactory } from '../factories/RebalancerContextFactory.js';
13
+ import {
14
+ MonitorEvent,
15
+ MonitorEventType,
16
+ MonitorPollingError,
17
+ MonitorStartError,
18
+ } from '../interfaces/IMonitor.js';
19
+ import type { IRebalancer } from '../interfaces/IRebalancer.js';
20
+ import type { IStrategy } from '../interfaces/IStrategy.js';
21
+ import { Metrics } from '../metrics/Metrics.js';
22
+ import { Monitor } from '../monitor/Monitor.js';
23
+ import { getRawBalances } from '../utils/balanceUtils.js';
24
+
25
+ export interface RebalancerServiceConfig {
26
+ /** Execution mode: 'manual' for one-off execution, 'daemon' for continuous monitoring */
27
+ mode: 'manual' | 'daemon';
28
+
29
+ /** Frequency to check balances in milliseconds (daemon mode only) */
30
+ checkFrequency?: number;
31
+
32
+ /** Enable monitor-only mode (no transactions executed) */
33
+ monitorOnly?: boolean;
34
+
35
+ /** Enable Prometheus metrics collection */
36
+ withMetrics?: boolean;
37
+
38
+ /** CoinGecko API key for token price fetching (required for metrics) */
39
+ coingeckoApiKey?: string;
40
+
41
+ /** Logger instance */
42
+ logger: Logger;
43
+
44
+ /** Service version for logging */
45
+ version?: string;
46
+ }
47
+
48
+ export interface ManualRebalanceRequest {
49
+ origin: string;
50
+ destination: string;
51
+ amount: string;
52
+ }
53
+
54
+ /**
55
+ * RebalancerService is the main orchestrator for the Hyperlane Warp Route Rebalancer.
56
+ * It supports both manual one-off rebalances and continuous daemon mode.
57
+ *
58
+ * @example Manual execution
59
+ * ```typescript
60
+ * const service = new RebalancerService(
61
+ * multiProvider,
62
+ * multiProtocolProvider,
63
+ * registry,
64
+ * rebalancerConfig,
65
+ * {
66
+ * mode: 'manual',
67
+ * logger: console,
68
+ * }
69
+ * );
70
+ * await service.executeManual({
71
+ * origin: 'ethereum',
72
+ * destination: 'arbitrum',
73
+ * amount: '1000',
74
+ * });
75
+ * ```
76
+ *
77
+ * @example Daemon mode
78
+ * ```typescript
79
+ * const service = new RebalancerService(
80
+ * multiProvider,
81
+ * multiProtocolProvider,
82
+ * registry,
83
+ * rebalancerConfig,
84
+ * {
85
+ * mode: 'daemon',
86
+ * checkFrequency: 60_000,
87
+ * withMetrics: true,
88
+ * coingeckoApiKey: process.env.COINGECKO_API_KEY,
89
+ * logger: console,
90
+ * }
91
+ * );
92
+ * await service.start();
93
+ * ```
94
+ */
95
+ export class RebalancerService {
96
+ private isExiting = false;
97
+ private logger: Logger;
98
+ private contextFactory?: RebalancerContextFactory;
99
+ private monitor?: Monitor;
100
+ private strategy?: IStrategy;
101
+ private rebalancer?: IRebalancer;
102
+ private metrics?: Metrics;
103
+ private mode: 'manual' | 'daemon';
104
+
105
+ constructor(
106
+ private readonly multiProvider: MultiProvider,
107
+ private readonly multiProtocolProvider: MultiProtocolProvider | undefined,
108
+ private readonly registry: IRegistry,
109
+ private readonly rebalancerConfig: RebalancerConfig,
110
+ private readonly config: RebalancerServiceConfig,
111
+ ) {
112
+ this.logger = config.logger;
113
+ this.mode = config.mode;
114
+ }
115
+
116
+ /**
117
+ * Initialize the service components
118
+ */
119
+ private async initialize(): Promise<void> {
120
+ if (this.contextFactory) {
121
+ // Already initialized
122
+ return;
123
+ }
124
+
125
+ this.logger.info('Initializing RebalancerService...');
126
+
127
+ // Create context factory
128
+ this.contextFactory = await RebalancerContextFactory.create(
129
+ this.rebalancerConfig,
130
+ this.multiProvider,
131
+ this.multiProtocolProvider,
132
+ this.registry,
133
+ this.logger,
134
+ );
135
+
136
+ // Create monitor (always needed for daemon mode)
137
+ if (this.mode === 'daemon') {
138
+ const checkFrequency = this.config.checkFrequency ?? 60_000;
139
+ this.monitor = this.contextFactory.createMonitor(checkFrequency);
140
+ }
141
+
142
+ // Create metrics if enabled
143
+ if (this.config.withMetrics) {
144
+ this.metrics = await this.contextFactory.createMetrics(
145
+ this.config.coingeckoApiKey,
146
+ );
147
+ this.logger.info('Metrics collection enabled');
148
+ }
149
+
150
+ // Create strategy
151
+ this.strategy = await this.contextFactory.createStrategy(this.metrics);
152
+
153
+ // Create rebalancer (unless in monitor-only mode)
154
+ if (!this.config.monitorOnly) {
155
+ this.rebalancer = this.contextFactory.createRebalancer(this.metrics);
156
+ } else {
157
+ this.logger.warn(
158
+ 'Running in monitorOnly mode: no transactions will be executed.',
159
+ );
160
+ }
161
+
162
+ this.logger.info('✅ RebalancerService initialized successfully');
163
+ }
164
+
165
+ /**
166
+ * Execute a manual one-off rebalance
167
+ */
168
+ async executeManual(request: ManualRebalanceRequest): Promise<void> {
169
+ await this.initialize();
170
+
171
+ assert(
172
+ this.rebalancer,
173
+ 'Rebalancer not available. MonitorOnly mode cannot execute manual rebalances.',
174
+ );
175
+
176
+ const { origin, destination, amount } = request;
177
+
178
+ this.logger.info(
179
+ `Manual rebalance strategy selected. Origin: ${origin}, Destination: ${destination}, Amount: ${amount}`,
180
+ );
181
+
182
+ const warpCore = this.contextFactory!.getWarpCore();
183
+ const originToken = warpCore.tokens.find(
184
+ (t: Token) => t.chainName === origin,
185
+ );
186
+
187
+ if (!originToken) {
188
+ const error = `Origin token not found for chain ${origin}`;
189
+ this.logger.error(error);
190
+ throw new Error(error);
191
+ }
192
+
193
+ // Validate amount
194
+ const amountNum = Number(amount);
195
+ assert(!isNaN(amountNum), 'Amount must be a valid number');
196
+ assert(amountNum > 0, 'Amount must be greater than 0');
197
+
198
+ try {
199
+ await this.rebalancer.rebalance([
200
+ {
201
+ origin,
202
+ destination,
203
+ amount: BigInt(toWei(amount, originToken.decimals)),
204
+ },
205
+ ]);
206
+ this.logger.info(
207
+ `✅ Manual rebalance from ${origin} to ${destination} for amount ${amount} submitted successfully.`,
208
+ );
209
+ } catch (error) {
210
+ this.logger.error(
211
+ { error },
212
+ `❌ Manual rebalance from ${origin} to ${destination} failed`,
213
+ );
214
+ throw error;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Start the rebalancer in daemon mode (continuous monitoring)
220
+ */
221
+ async start(): Promise<void> {
222
+ if (this.mode !== 'daemon') {
223
+ throw new Error('start() can only be called in daemon mode');
224
+ }
225
+
226
+ await this.initialize();
227
+
228
+ assert(this.monitor, 'Monitor must be initialized for daemon mode');
229
+
230
+ // Setup monitor event listeners
231
+ this.monitor
232
+ .on(MonitorEventType.TokenInfo, this.onTokenInfo.bind(this))
233
+ .on(MonitorEventType.Error, this.onMonitorError.bind(this))
234
+ .on(MonitorEventType.Start, this.onMonitorStart.bind(this));
235
+
236
+ // Set up signal handlers for graceful shutdown
237
+ process.on('SIGINT', () => this.gracefulShutdown());
238
+ process.on('SIGTERM', () => this.gracefulShutdown());
239
+
240
+ try {
241
+ await this.monitor.start();
242
+ } catch (error) {
243
+ this.logger.error({ error }, 'Rebalancer startup error');
244
+ throw error;
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Stop the rebalancer daemon
250
+ */
251
+ async stop(): Promise<void> {
252
+ if (this.monitor) {
253
+ await this.monitor.stop();
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Gracefully shutdown the service
259
+ */
260
+ async gracefulShutdown(): Promise<void> {
261
+ if (this.isExiting) {
262
+ return;
263
+ }
264
+ this.isExiting = true;
265
+
266
+ this.logger.info('Gracefully shutting down rebalancer...');
267
+ await this.stop();
268
+
269
+ // Unregister listeners to prevent them from being called again during shutdown
270
+ process.removeAllListeners('SIGINT');
271
+ process.removeAllListeners('SIGTERM');
272
+
273
+ this.logger.info('Rebalancer shutdown complete');
274
+ process.exit(0);
275
+ }
276
+
277
+ /**
278
+ * Event handler for token info updates from monitor
279
+ */
280
+ private async onTokenInfo(event: MonitorEvent): Promise<void> {
281
+ if (this.metrics) {
282
+ await Promise.all(
283
+ event.tokensInfo.map((tokenInfo) =>
284
+ this.metrics!.processToken(tokenInfo),
285
+ ),
286
+ );
287
+ }
288
+
289
+ const rawBalances = getRawBalances(
290
+ Object.keys(this.rebalancerConfig.strategyConfig.chains),
291
+ event,
292
+ this.logger,
293
+ );
294
+
295
+ const rebalancingRoutes = this.strategy!.getRebalancingRoutes(rawBalances);
296
+
297
+ this.rebalancer
298
+ ?.rebalance(rebalancingRoutes)
299
+ .then(() => {
300
+ this.metrics?.recordRebalancerSuccess();
301
+ this.logger.info('Rebalancer completed a cycle successfully');
302
+ })
303
+ .catch((error: any) => {
304
+ this.metrics?.recordRebalancerFailure();
305
+ this.logger.error({ error }, 'Error while rebalancing');
306
+ });
307
+ }
308
+
309
+ /**
310
+ * Event handler for monitor errors
311
+ */
312
+ private onMonitorError(error: Error): void {
313
+ if (error instanceof MonitorPollingError) {
314
+ this.logger.error(error.message);
315
+ this.metrics?.recordPollingError();
316
+ } else if (error instanceof MonitorStartError) {
317
+ this.logger.error(error.message);
318
+ throw error;
319
+ } else {
320
+ this.logger.error(
321
+ { error },
322
+ 'An unexpected error occurred in the monitor',
323
+ );
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Event handler for monitor start
329
+ */
330
+ private onMonitorStart(): void {
331
+ this.logger.info('Rebalancer started successfully 🚀');
332
+ }
333
+ }
@@ -0,0 +1,131 @@
1
+ import chai, { expect } from 'chai';
2
+ import chaiAsPromised from 'chai-as-promised';
3
+ import { ethers } from 'ethers';
4
+ import { pino } from 'pino';
5
+ import Sinon from 'sinon';
6
+
7
+ import { chainMetadata } from '@hyperlane-xyz/registry';
8
+ import { ChainMetadataManager } from '@hyperlane-xyz/sdk';
9
+
10
+ import { RebalancingRoute } from '../interfaces/IStrategy.js';
11
+ import { MockRebalancer, buildTestConfig } from '../test/helpers.js';
12
+ import { ExplorerClient } from '../utils/ExplorerClient.js';
13
+
14
+ import { WithInflightGuard } from './WithInflightGuard.js';
15
+
16
+ chai.use(chaiAsPromised);
17
+
18
+ const testLogger = pino({ level: 'silent' });
19
+
20
+ describe('WithInflightGuard', () => {
21
+ it('forwards empty routes without calling Explorer', async () => {
22
+ const config = buildTestConfig();
23
+
24
+ const rebalancer = new MockRebalancer();
25
+ const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
26
+
27
+ const explorer = new ExplorerClient('http://localhost');
28
+ const explorerSpy = Sinon.stub(explorer, 'hasUndeliveredRebalance');
29
+
30
+ const guard = new WithInflightGuard(
31
+ config,
32
+ rebalancer,
33
+ explorer,
34
+ ethers.Wallet.createRandom().address,
35
+ new ChainMetadataManager(chainMetadata as any),
36
+ testLogger,
37
+ );
38
+
39
+ await guard.rebalance([]);
40
+
41
+ expect(explorerSpy.called).to.be.false;
42
+ expect(rebalanceSpy.calledOnce).to.be.true;
43
+ expect(rebalanceSpy.calledWith([])).to.be.true;
44
+ });
45
+
46
+ it('calls underlying rebalancer when no inflight is detected', async () => {
47
+ const config = buildTestConfig({}, ['ethereum', 'arbitrum']);
48
+ const routes: RebalancingRoute[] = [{ origin: 'ethereum' } as any];
49
+
50
+ const rebalancer = new MockRebalancer();
51
+ const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
52
+
53
+ const explorer = new ExplorerClient('http://localhost');
54
+ const explorerSpy = Sinon.stub(
55
+ explorer,
56
+ 'hasUndeliveredRebalance',
57
+ ).resolves(false);
58
+
59
+ const guard = new WithInflightGuard(
60
+ config,
61
+ rebalancer,
62
+ explorer,
63
+ ethers.Wallet.createRandom().address,
64
+ new ChainMetadataManager(chainMetadata as any),
65
+ testLogger,
66
+ );
67
+
68
+ await guard.rebalance(routes);
69
+
70
+ expect(explorerSpy.calledOnce).to.be.true;
71
+ expect(rebalanceSpy.calledOnce).to.be.true;
72
+ expect(rebalanceSpy.calledWith(routes)).to.be.true;
73
+ });
74
+
75
+ it('skips rebalancing when inflight is detected', async () => {
76
+ const config = buildTestConfig({}, ['ethereum', 'arbitrum']);
77
+ const routes: RebalancingRoute[] = [{ origin: 'ethereum' } as any];
78
+
79
+ const rebalancer = new MockRebalancer();
80
+ const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
81
+
82
+ const explorer = new ExplorerClient('http://localhost');
83
+ const explorerSpy = Sinon.stub(
84
+ explorer,
85
+ 'hasUndeliveredRebalance',
86
+ ).resolves(true);
87
+
88
+ const guard = new WithInflightGuard(
89
+ config,
90
+ rebalancer,
91
+ explorer,
92
+ ethers.Wallet.createRandom().address,
93
+ new ChainMetadataManager(chainMetadata as any),
94
+ testLogger,
95
+ );
96
+
97
+ await guard.rebalance(routes);
98
+
99
+ expect(explorerSpy.calledOnce).to.be.true;
100
+ expect(rebalanceSpy.called).to.be.false;
101
+ });
102
+
103
+ it('propagates explorer query error', async () => {
104
+ const config = buildTestConfig({}, ['ethereum', 'arbitrum']);
105
+ const routes: RebalancingRoute[] = [{ origin: 'ethereum' } as any];
106
+
107
+ const rebalancer = new MockRebalancer();
108
+ const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
109
+
110
+ const explorer = new ExplorerClient('http://localhost');
111
+ const explorerSpy = Sinon.stub(explorer, 'hasUndeliveredRebalance').rejects(
112
+ new Error('Explorer HTTP 405'),
113
+ );
114
+
115
+ const guard = new WithInflightGuard(
116
+ config,
117
+ rebalancer,
118
+ explorer,
119
+ ethers.Wallet.createRandom().address,
120
+ new ChainMetadataManager(chainMetadata as any),
121
+ testLogger,
122
+ );
123
+
124
+ await expect(guard.rebalance(routes)).to.be.rejectedWith(
125
+ 'Explorer HTTP 405',
126
+ );
127
+
128
+ expect(explorerSpy.calledOnce).to.be.true;
129
+ expect(rebalanceSpy.called).to.be.false;
130
+ });
131
+ });
@@ -0,0 +1,67 @@
1
+ import type { Logger } from 'pino';
2
+
3
+ import { ChainMetadataManager } from '@hyperlane-xyz/sdk';
4
+
5
+ import { RebalancerConfig } from '../config/RebalancerConfig.js';
6
+ import type { IRebalancer } from '../interfaces/IRebalancer.js';
7
+ import type { RebalancingRoute } from '../interfaces/IStrategy.js';
8
+ import { ExplorerClient } from '../utils/ExplorerClient.js';
9
+
10
+ /**
11
+ * Prevents rebalancing if there are inflight rebalances for the warp route.
12
+ */
13
+ export class WithInflightGuard implements IRebalancer {
14
+ private readonly logger: Logger;
15
+
16
+ constructor(
17
+ private readonly config: RebalancerConfig,
18
+ private readonly rebalancer: IRebalancer,
19
+ private readonly explorer: ExplorerClient,
20
+ private readonly txSender: string,
21
+ private readonly chainManager: ChainMetadataManager,
22
+ logger: Logger,
23
+ ) {
24
+ this.logger = logger.child({ class: WithInflightGuard.name });
25
+ }
26
+
27
+ async rebalance(routes: RebalancingRoute[]): Promise<void> {
28
+ // Always enforce the inflight guard
29
+ if (routes.length === 0) {
30
+ return this.rebalancer.rebalance(routes);
31
+ }
32
+
33
+ const chains = Object.keys(this.config.strategyConfig.chains);
34
+ const bridges = chains.map(
35
+ (chain) => this.config.strategyConfig.chains[chain].bridge,
36
+ );
37
+ const domains = chains.map((chain) => this.chainManager.getDomainId(chain));
38
+
39
+ let hasInflightRebalances = false;
40
+ try {
41
+ hasInflightRebalances = await this.explorer.hasUndeliveredRebalance(
42
+ {
43
+ bridges,
44
+ domains: Array.from(new Set(domains)),
45
+ txSender: this.txSender,
46
+ limit: 5,
47
+ },
48
+ this.logger,
49
+ );
50
+ } catch (e: any) {
51
+ this.logger.error(
52
+ { status: e.status, body: e.body },
53
+ 'Explorer inflight query failed',
54
+ );
55
+ throw e;
56
+ }
57
+
58
+ if (hasInflightRebalances) {
59
+ this.logger.info(
60
+ 'Inflight rebalance detected via Explorer; skipping this cycle',
61
+ );
62
+ return;
63
+ }
64
+
65
+ return this.rebalancer.rebalance(routes);
66
+ }
67
+ }
@@ -0,0 +1,112 @@
1
+ import chai, { expect } from 'chai';
2
+ import chaiAsPromised from 'chai-as-promised';
3
+ import { pino } from 'pino';
4
+ import Sinon from 'sinon';
5
+
6
+ import { RebalancerStrategyOptions } from '@hyperlane-xyz/sdk';
7
+
8
+ import { RebalancingRoute } from '../interfaces/IStrategy.js';
9
+ import { MockRebalancer, buildTestConfig } from '../test/helpers.js';
10
+
11
+ import { WithSemaphore } from './WithSemaphore.js';
12
+
13
+ chai.use(chaiAsPromised);
14
+
15
+ const testLogger = pino({ level: 'silent' });
16
+
17
+ describe('WithSemaphore', () => {
18
+ it('should call the underlying rebalancer', async () => {
19
+ const config = buildTestConfig();
20
+
21
+ const routes = [
22
+ {
23
+ origin: 'chain1',
24
+ } as any as RebalancingRoute,
25
+ ];
26
+
27
+ const rebalancer = new MockRebalancer();
28
+ const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
29
+ const withSemaphore = new WithSemaphore(config, rebalancer, testLogger);
30
+ await withSemaphore.rebalance(routes);
31
+
32
+ expect(rebalanceSpy.calledOnce).to.be.true;
33
+ expect(rebalanceSpy.calledWith(routes)).to.be.true;
34
+ });
35
+
36
+ it('should return early if there are no routes', async () => {
37
+ const config = buildTestConfig();
38
+
39
+ const rebalancer = new MockRebalancer();
40
+ const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
41
+ const withSemaphore = new WithSemaphore(config, rebalancer, testLogger);
42
+ await withSemaphore.rebalance([]);
43
+
44
+ expect(rebalanceSpy.calledOnce).to.be.false;
45
+ });
46
+
47
+ it('should return early if rebalance occurs before waitUntil is reached', async () => {
48
+ const config = buildTestConfig();
49
+
50
+ const routes = [
51
+ {
52
+ origin: 'chain1',
53
+ } as any as RebalancingRoute,
54
+ ];
55
+
56
+ const rebalancer = new MockRebalancer();
57
+ const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
58
+ const withSemaphore = new WithSemaphore(config, rebalancer, testLogger);
59
+ await withSemaphore.rebalance(routes);
60
+
61
+ expect(rebalanceSpy.calledOnce).to.be.true;
62
+ expect(rebalanceSpy.calledWith(routes)).to.be.true;
63
+
64
+ rebalanceSpy.resetHistory();
65
+ await withSemaphore.rebalance(routes);
66
+
67
+ expect(rebalanceSpy.calledOnce).to.be.false;
68
+ });
69
+
70
+ it('should throw if a chain is missing', async () => {
71
+ const config = buildTestConfig({
72
+ strategyConfig: {
73
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
74
+ chains: {},
75
+ },
76
+ });
77
+
78
+ const routes = [
79
+ {
80
+ origin: 'chain1',
81
+ } as any as RebalancingRoute,
82
+ ];
83
+
84
+ const rebalancer = new MockRebalancer();
85
+ const withSemaphore = new WithSemaphore(config, rebalancer, testLogger);
86
+
87
+ await expect(withSemaphore.rebalance(routes)).to.be.rejectedWith(
88
+ `Chain ${routes[0].origin} not found in config`,
89
+ );
90
+ });
91
+
92
+ it('should not execute if another rebalance is currently executing', async () => {
93
+ const config = buildTestConfig();
94
+
95
+ const routes = [
96
+ {
97
+ origin: 'chain1',
98
+ } as any as RebalancingRoute,
99
+ ];
100
+
101
+ const rebalancer = new MockRebalancer();
102
+ const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
103
+ const withSemaphore = new WithSemaphore(config, rebalancer, testLogger);
104
+
105
+ const rebalancePromise1 = withSemaphore.rebalance(routes);
106
+ const rebalancePromise2 = withSemaphore.rebalance(routes);
107
+ await rebalancePromise1;
108
+ await rebalancePromise2;
109
+
110
+ expect(rebalanceSpy.calledOnce).to.be.true;
111
+ });
112
+ });