@hyperlane-xyz/rebalancer 3.2.1 → 25.5.0

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 (39) hide show
  1. package/dist/bridges/LiFiBridge.d.ts +12 -2
  2. package/dist/bridges/LiFiBridge.d.ts.map +1 -1
  3. package/dist/bridges/LiFiBridge.js +36 -1
  4. package/dist/bridges/LiFiBridge.js.map +1 -1
  5. package/dist/bridges/LiFiBridge.test.d.ts +2 -0
  6. package/dist/bridges/LiFiBridge.test.d.ts.map +1 -0
  7. package/dist/bridges/LiFiBridge.test.js +412 -0
  8. package/dist/bridges/LiFiBridge.test.js.map +1 -0
  9. package/dist/config/types.d.ts +1 -0
  10. package/dist/config/types.d.ts.map +1 -1
  11. package/dist/config/types.js +1 -0
  12. package/dist/config/types.js.map +1 -1
  13. package/dist/e2e/harness/MockExternalBridge.d.ts.map +1 -1
  14. package/dist/e2e/harness/MockExternalBridge.js +20 -0
  15. package/dist/e2e/harness/MockExternalBridge.js.map +1 -1
  16. package/dist/interfaces/IExternalBridge.d.ts +3 -2
  17. package/dist/interfaces/IExternalBridge.d.ts.map +1 -1
  18. package/dist/service.js +0 -0
  19. package/dist/test/lifiMocks.d.ts +1 -0
  20. package/dist/test/lifiMocks.d.ts.map +1 -1
  21. package/dist/test/lifiMocks.js +36 -6
  22. package/dist/test/lifiMocks.js.map +1 -1
  23. package/dist/tracking/ActionTracker.d.ts.map +1 -1
  24. package/dist/tracking/ActionTracker.js +44 -4
  25. package/dist/tracking/ActionTracker.js.map +1 -1
  26. package/dist/tracking/ActionTracker.test.js +284 -1
  27. package/dist/tracking/ActionTracker.test.js.map +1 -1
  28. package/dist/tracking/types.d.ts +3 -0
  29. package/dist/tracking/types.d.ts.map +1 -1
  30. package/package.json +7 -10
  31. package/src/bridges/LiFiBridge.test.ts +510 -0
  32. package/src/bridges/LiFiBridge.ts +84 -48
  33. package/src/config/types.ts +2 -0
  34. package/src/e2e/harness/MockExternalBridge.ts +32 -0
  35. package/src/interfaces/IExternalBridge.ts +3 -2
  36. package/src/test/lifiMocks.ts +43 -6
  37. package/src/tracking/ActionTracker.test.ts +336 -1
  38. package/src/tracking/ActionTracker.ts +53 -4
  39. package/src/tracking/types.ts +3 -0
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  EVM,
3
+ type LiFiStep,
4
+ type Route,
3
5
  type RouteExtended,
4
6
  convertQuoteToRoute,
5
7
  createConfig,
@@ -12,6 +14,7 @@ import type { Logger } from 'pino';
12
14
  import { type Chain, createWalletClient, http } from 'viem';
13
15
  import { privateKeyToAccount } from 'viem/accounts';
14
16
  import { arbitrum, base, mainnet, optimism } from 'viem/chains';
17
+ import { assert } from '@hyperlane-xyz/utils';
15
18
 
16
19
  import type {
17
20
  BridgeQuote,
@@ -28,41 +31,6 @@ import type {
28
31
  */
29
32
  const LIFI_API_BASE = 'https://li.quest/v1';
30
33
 
31
- /**
32
- * LiFi quote response structure (partial, only fields we need).
33
- */
34
- interface LiFiQuoteResponse {
35
- id: string;
36
- tool: string;
37
- action: {
38
- fromAmount: string;
39
- toAmount?: string;
40
- };
41
- estimate: {
42
- fromAmount: string;
43
- toAmount: string;
44
- toAmountMin: string;
45
- executionDuration: number;
46
- gasCosts?: Array<{
47
- type: string;
48
- amount: string;
49
- token: {
50
- address: string;
51
- symbol: string;
52
- };
53
- }>;
54
- feeCosts?: Array<{
55
- name: string;
56
- amount: string;
57
- included: boolean;
58
- token: {
59
- address: string;
60
- symbol: string;
61
- };
62
- }>;
63
- };
64
- }
65
-
66
34
  /**
67
35
  * Known chains for viem - add more as needed.
68
36
  * TODO: can we think of a cleaner way to do this?
@@ -111,7 +79,7 @@ function getViemChain(chainId: number, rpcUrl?: string): Chain {
111
79
  */
112
80
  export class LiFiBridge implements IExternalBridge {
113
81
  private static readonly NATIVE_TOKEN_ADDRESS =
114
- '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE';
82
+ '0x0000000000000000000000000000000000000000';
115
83
 
116
84
  readonly externalBridgeId = 'lifi';
117
85
  readonly logger: Logger;
@@ -164,7 +132,7 @@ export class LiFiBridge implements IExternalBridge {
164
132
  *
165
133
  * Returns route data ready for execution.
166
134
  */
167
- async quote(params: BridgeQuoteParams): Promise<BridgeQuote> {
135
+ async quote(params: BridgeQuoteParams): Promise<BridgeQuote<LiFiStep>> {
168
136
  this.initialize();
169
137
 
170
138
  // Validate that exactly one of fromAmount or toAmount is provided
@@ -177,6 +145,15 @@ export class LiFiBridge implements IExternalBridge {
177
145
  throw new Error('Must specify either fromAmount or toAmount');
178
146
  }
179
147
 
148
+ assert(
149
+ params.fromAmount === undefined || params.fromAmount > 0n,
150
+ 'fromAmount must be positive',
151
+ );
152
+ assert(
153
+ params.toAmount === undefined || params.toAmount > 0n,
154
+ 'toAmount must be positive',
155
+ );
156
+
180
157
  // Dispatch to appropriate quote method
181
158
  if (params.toAmount !== undefined) {
182
159
  return this.quoteByReceivingAmount(params);
@@ -191,7 +168,7 @@ export class LiFiBridge implements IExternalBridge {
191
168
  */
192
169
  private async quoteBySpendingAmount(
193
170
  params: BridgeQuoteParams,
194
- ): Promise<BridgeQuote> {
171
+ ): Promise<BridgeQuote<LiFiStep>> {
195
172
  this.logger.debug({ params }, 'Requesting LiFi quote by spending amount');
196
173
 
197
174
  const quote = await getQuote({
@@ -207,9 +184,7 @@ export class LiFiBridge implements IExternalBridge {
207
184
  order: 'RECOMMENDED',
208
185
  });
209
186
 
210
- const { gasCosts, feeCosts } = this.extractCosts(
211
- quote as unknown as LiFiQuoteResponse,
212
- );
187
+ const { gasCosts, feeCosts } = this.extractCosts(quote);
213
188
 
214
189
  this.logger.info(
215
190
  {
@@ -235,6 +210,7 @@ export class LiFiBridge implements IExternalBridge {
235
210
  gasCosts,
236
211
  feeCosts,
237
212
  route: quote, // Store full quote for conversion to route
213
+ requestParams: { ...params },
238
214
  };
239
215
  }
240
216
 
@@ -244,7 +220,7 @@ export class LiFiBridge implements IExternalBridge {
244
220
  */
245
221
  private async quoteByReceivingAmount(
246
222
  params: BridgeQuoteParams,
247
- ): Promise<BridgeQuote> {
223
+ ): Promise<BridgeQuote<LiFiStep>> {
248
224
  this.logger.debug({ params }, 'Requesting LiFi quote by receiving amount');
249
225
 
250
226
  const queryParams = new URLSearchParams({
@@ -280,7 +256,7 @@ export class LiFiBridge implements IExternalBridge {
280
256
  );
281
257
  }
282
258
 
283
- const quote: LiFiQuoteResponse = await response.json();
259
+ const quote: LiFiStep = await response.json();
284
260
  const { gasCosts, feeCosts } = this.extractCosts(quote);
285
261
 
286
262
  this.logger.info(
@@ -307,6 +283,7 @@ export class LiFiBridge implements IExternalBridge {
307
283
  gasCosts,
308
284
  feeCosts,
309
285
  route: quote, // Store full quote for conversion to route
286
+ requestParams: { ...params },
310
287
  };
311
288
  }
312
289
 
@@ -315,7 +292,7 @@ export class LiFiBridge implements IExternalBridge {
315
292
  * - gasCosts: Sum of all gas costs (transaction fees)
316
293
  * - feeCosts: Sum of non-included fee costs (protocol fees not deducted from amount)
317
294
  */
318
- private extractCosts(quote: LiFiQuoteResponse): {
295
+ private extractCosts(quote: LiFiStep): {
319
296
  gasCosts: bigint;
320
297
  feeCosts: bigint;
321
298
  } {
@@ -350,15 +327,15 @@ export class LiFiBridge implements IExternalBridge {
350
327
  * @param privateKey - Private key hex string (0x-prefixed) for signing the transaction
351
328
  */
352
329
  async execute(
353
- quote: BridgeQuote,
330
+ quote: BridgeQuote<LiFiStep>,
354
331
  privateKey: string,
355
332
  ): Promise<BridgeTransferResult> {
356
333
  this.initialize();
357
334
 
358
335
  // Convert quote to route for execution
359
- const route = convertQuoteToRoute(
360
- quote.route as Parameters<typeof convertQuoteToRoute>[0],
361
- );
336
+ const route = convertQuoteToRoute(quote.route);
337
+
338
+ this.validateRouteAgainstRequest(route, quote.requestParams);
362
339
 
363
340
  const fromChain = route.fromChainId;
364
341
  const toChain = route.toChainId;
@@ -478,6 +455,65 @@ export class LiFiBridge implements IExternalBridge {
478
455
  };
479
456
  }
480
457
 
458
+ /**
459
+ * Validate that the route returned by LiFi matches the original request parameters.
460
+ * Prevents execution against wrong chains, tokens, or recipients if the bridge API
461
+ * returns a route that diverges from what was originally requested.
462
+ *
463
+ * TODO: Layer 2 validation — validate transaction calldata in route.steps[].transactionRequest
464
+ * and route.steps[0].estimate.approvalAddress against a known whitelist.
465
+ */
466
+ private validateRouteAgainstRequest(
467
+ route: Route,
468
+ requestParams: BridgeQuoteParams,
469
+ ): void {
470
+ assert(
471
+ route.fromChainId === requestParams.fromChain,
472
+ `Route fromChainId ${route.fromChainId} does not match requested ${requestParams.fromChain}`,
473
+ );
474
+ assert(
475
+ route.toChainId === requestParams.toChain,
476
+ `Route toChainId ${route.toChainId} does not match requested ${requestParams.toChain}`,
477
+ );
478
+ assert(
479
+ route.fromToken.address.toLowerCase() ===
480
+ requestParams.fromToken.toLowerCase(),
481
+ `Route fromToken ${route.fromToken.address} does not match requested ${requestParams.fromToken}`,
482
+ );
483
+ assert(
484
+ route.toToken.address.toLowerCase() ===
485
+ requestParams.toToken.toLowerCase(),
486
+ `Route toToken ${route.toToken.address} does not match requested ${requestParams.toToken}`,
487
+ );
488
+ const expectedToAddress = (
489
+ requestParams.toAddress ?? requestParams.fromAddress
490
+ ).toLowerCase();
491
+ assert(
492
+ route.toAddress?.toLowerCase() === expectedToAddress,
493
+ `Route toAddress ${route.toAddress} does not match requested ${expectedToAddress}`,
494
+ );
495
+ assert(
496
+ route.fromAddress?.toLowerCase() ===
497
+ requestParams.fromAddress.toLowerCase(),
498
+ `Route fromAddress ${route.fromAddress} does not match requested ${requestParams.fromAddress}`,
499
+ );
500
+ const routeFromAmount = BigInt(route.fromAmount);
501
+ if (requestParams.fromAmount !== undefined) {
502
+ assert(
503
+ routeFromAmount === requestParams.fromAmount,
504
+ `Route fromAmount ${route.fromAmount} does not match requested ${requestParams.fromAmount}`,
505
+ );
506
+ }
507
+ if (requestParams.toAmount !== undefined) {
508
+ const routeToAmount = BigInt(route.toAmount);
509
+ assert(
510
+ routeToAmount >= requestParams.toAmount,
511
+ `Route toAmount ${route.toAmount} is less than requested ${requestParams.toAmount}`,
512
+ );
513
+ }
514
+ assert(routeFromAmount > 0n, 'Route fromAmount must be positive');
515
+ }
516
+
481
517
  /**
482
518
  * Get the status of a bridge transfer.
483
519
  * Uses SDK's built-in status tracking.
@@ -118,6 +118,8 @@ export const RebalancerStrategySchema = z
118
118
  export const DEFAULT_INTENT_TTL_S = 604800; // 7 days
119
119
  export const DEFAULT_INTENT_TTL_MS = DEFAULT_INTENT_TTL_S * 1_000;
120
120
 
121
+ export const DEFAULT_MOVEMENT_STALENESS_MS = 30 * 60 * 1_000; // 30 minutes
122
+
121
123
  export const LiFiBridgeConfigSchema = z.object({
122
124
  integrator: z.string(),
123
125
  defaultSlippage: z.number().optional(),
@@ -105,6 +105,7 @@ export class MockExternalBridge implements IExternalBridge {
105
105
  gasCosts,
106
106
  feeCosts: 0n,
107
107
  route,
108
+ requestParams: params,
108
109
  };
109
110
  }
110
111
 
@@ -197,6 +198,14 @@ export class MockExternalBridge implements IExternalBridge {
197
198
  return { status: 'not_found' };
198
199
  }
199
200
 
201
+ const dispatchedMessages =
202
+ HyperlaneCore.getDispatchedMessages(dispatchTxReceipt);
203
+ assert(
204
+ dispatchedMessages.length === 1,
205
+ `Expected exactly 1 dispatched message, got ${dispatchedMessages.length} for tx ${txHash}`,
206
+ );
207
+ const dispatchedMsgId = dispatchedMessages[0].id;
208
+
200
209
  const relayer = new HyperlaneRelayer({ core: this.core });
201
210
  const receipts = await relayer.relayAll(dispatchTxReceipt);
202
211
 
@@ -206,7 +215,30 @@ export class MockExternalBridge implements IExternalBridge {
206
215
  receipts[toChain] ??
207
216
  receipts[destinationDomain];
208
217
 
218
+ // If relayAll didn't produce receipts (e.g. message already delivered),
219
+ // fall back to checking on-chain delivery status directly.
209
220
  if (!destinationReceipts || destinationReceipts.length === 0) {
221
+ const destMailbox = this.core.getContracts(toChainName).mailbox;
222
+ const isDelivered = await destMailbox.delivered(dispatchedMsgId);
223
+ if (isDelivered) {
224
+ const receivedAmount = await this.getTransferredAmount(
225
+ provider,
226
+ dispatchTxReceipt,
227
+ );
228
+ // Find the actual destination chain process tx
229
+ const processEvents = await destMailbox.queryFilter(
230
+ destMailbox.filters.ProcessId(dispatchedMsgId),
231
+ );
232
+ assert(
233
+ processEvents.length > 0,
234
+ `No ProcessId event found for message ${dispatchedMsgId} on ${toChainName}`,
235
+ );
236
+ return {
237
+ status: 'complete',
238
+ receivingTxHash: processEvents[0].transactionHash,
239
+ receivedAmount,
240
+ };
241
+ }
210
242
  return { status: 'not_found' };
211
243
  }
212
244
 
@@ -35,7 +35,7 @@ export interface BridgeQuoteParams {
35
35
  /**
36
36
  * Quote response from a bridge.
37
37
  */
38
- export interface BridgeQuote {
38
+ export interface BridgeQuote<R = unknown> {
39
39
  id: string; // Unique quote identifier
40
40
  tool: string; // Bridge/DEX tool used (e.g., 'stargate', 'across')
41
41
  fromAmount: bigint; // Amount being sent (input required)
@@ -44,7 +44,8 @@ export interface BridgeQuote {
44
44
  executionDuration: number; // Estimated execution time in seconds
45
45
  gasCosts: bigint; // Sum of gas costs for the bridge operation
46
46
  feeCosts: bigint; // Sum of non-included fee costs (protocol fees, etc.)
47
- route: unknown; // Bridge-specific route data for execution
47
+ route: R; // Bridge-specific route data for execution
48
+ requestParams: BridgeQuoteParams; // Original request parameters
48
49
  }
49
50
 
50
51
  /**
@@ -2,6 +2,7 @@ import Sinon, { type SinonStub } from 'sinon';
2
2
 
3
3
  import type {
4
4
  BridgeQuote,
5
+ BridgeQuoteParams,
5
6
  BridgeTransferStatus,
6
7
  } from '../interfaces/IExternalBridge.js';
7
8
 
@@ -119,22 +120,58 @@ export function mockLiFiStatus(
119
120
 
120
121
  /**
121
122
  * Create a mock BridgeQuote for testing.
123
+ * The route structure includes all fields read by validateRouteAgainstRequest.
122
124
  */
123
125
  export function createMockBridgeQuote(
124
126
  overrides?: Partial<BridgeQuote>,
125
127
  ): BridgeQuote {
128
+ const route = overrides?.route as
129
+ | { action?: { fromChainId?: number; toChainId?: number } }
130
+ | undefined;
131
+
132
+ const defaultRequestParams: BridgeQuoteParams = {
133
+ fromChain: 42161,
134
+ toChain: 1399811149,
135
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
136
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
137
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
138
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
139
+ fromAmount: 10000000000n,
140
+ };
141
+ const requestParams: BridgeQuoteParams = {
142
+ ...defaultRequestParams,
143
+ ...overrides?.requestParams,
144
+ };
145
+
146
+ const fromChainId = route?.action?.fromChainId ?? requestParams.fromChain;
147
+ const toChainId = route?.action?.toChainId ?? requestParams.toChain;
148
+ const fromAmount =
149
+ overrides?.fromAmount ?? requestParams.fromAmount ?? 10000000000n;
150
+ const toAmount = overrides?.toAmount ?? 9950000000n;
151
+ const toAmountMin = overrides?.toAmountMin ?? 9900000000n;
152
+
126
153
  return {
127
154
  id: 'quote-123',
128
155
  tool: 'across',
129
- fromAmount: 10000000000n,
130
- toAmount: 9950000000n,
131
- toAmountMin: 9900000000n,
156
+ fromAmount,
157
+ toAmount,
158
+ toAmountMin,
132
159
  executionDuration: 300,
133
- gasCosts: 50000000n, // Default mock gas costs
134
- feeCosts: 0n, // Default mock fee costs
160
+ gasCosts: 50000000n,
161
+ feeCosts: 0n,
162
+ // Route includes all fields read by validateRouteAgainstRequest for validation compatibility
135
163
  route: {
136
- action: { fromChainId: 42161, toChainId: 1399811149 },
164
+ action: { fromChainId, toChainId },
165
+ fromChainId,
166
+ toChainId,
167
+ fromToken: { address: requestParams.fromToken },
168
+ toToken: { address: requestParams.toToken },
169
+ fromAddress: requestParams.fromAddress,
170
+ toAddress: requestParams.toAddress ?? requestParams.fromAddress,
171
+ fromAmount: fromAmount.toString(),
172
+ toAmount: toAmount.toString(),
137
173
  },
174
+ requestParams,
138
175
  ...overrides,
139
176
  };
140
177
  }