@hyperlane-xyz/rebalancer 2.0.0 → 3.1.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 (213) hide show
  1. package/dist/bridges/LiFiBridge.d.ts +67 -0
  2. package/dist/bridges/LiFiBridge.d.ts.map +1 -0
  3. package/dist/bridges/LiFiBridge.js +386 -0
  4. package/dist/bridges/LiFiBridge.js.map +1 -0
  5. package/dist/config/RebalancerConfig.d.ts +8 -2
  6. package/dist/config/RebalancerConfig.d.ts.map +1 -1
  7. package/dist/config/RebalancerConfig.js +9 -4
  8. package/dist/config/RebalancerConfig.js.map +1 -1
  9. package/dist/config/RebalancerConfig.test.js +135 -1
  10. package/dist/config/RebalancerConfig.test.js.map +1 -1
  11. package/dist/config/types.d.ts +1023 -304
  12. package/dist/config/types.d.ts.map +1 -1
  13. package/dist/config/types.js +113 -10
  14. package/dist/config/types.js.map +1 -1
  15. package/dist/core/InventoryRebalancer.d.ts +190 -0
  16. package/dist/core/InventoryRebalancer.d.ts.map +1 -0
  17. package/dist/core/InventoryRebalancer.js +892 -0
  18. package/dist/core/InventoryRebalancer.js.map +1 -0
  19. package/dist/core/InventoryRebalancer.test.d.ts +2 -0
  20. package/dist/core/InventoryRebalancer.test.d.ts.map +1 -0
  21. package/dist/core/InventoryRebalancer.test.js +1382 -0
  22. package/dist/core/InventoryRebalancer.test.js.map +1 -0
  23. package/dist/core/Rebalancer.d.ts +11 -4
  24. package/dist/core/Rebalancer.d.ts.map +1 -1
  25. package/dist/core/Rebalancer.js +92 -9
  26. package/dist/core/Rebalancer.js.map +1 -1
  27. package/dist/core/Rebalancer.test.js +82 -49
  28. package/dist/core/Rebalancer.test.js.map +1 -1
  29. package/dist/core/RebalancerOrchestrator.d.ts +30 -9
  30. package/dist/core/RebalancerOrchestrator.d.ts.map +1 -1
  31. package/dist/core/RebalancerOrchestrator.js +79 -71
  32. package/dist/core/RebalancerOrchestrator.js.map +1 -1
  33. package/dist/core/RebalancerOrchestrator.test.d.ts +2 -0
  34. package/dist/core/RebalancerOrchestrator.test.d.ts.map +1 -0
  35. package/dist/core/RebalancerOrchestrator.test.js +719 -0
  36. package/dist/core/RebalancerOrchestrator.test.js.map +1 -0
  37. package/dist/core/RebalancerService.d.ts +7 -3
  38. package/dist/core/RebalancerService.d.ts.map +1 -1
  39. package/dist/core/RebalancerService.js +44 -24
  40. package/dist/core/RebalancerService.js.map +1 -1
  41. package/dist/core/RebalancerService.test.js +74 -110
  42. package/dist/core/RebalancerService.test.js.map +1 -1
  43. package/dist/e2e/collateral-deficit.e2e-test.js +1 -3
  44. package/dist/e2e/collateral-deficit.e2e-test.js.map +1 -1
  45. package/dist/e2e/composite.e2e-test.js.map +1 -1
  46. package/dist/e2e/harness/BridgeSetup.d.ts +6 -0
  47. package/dist/e2e/harness/BridgeSetup.d.ts.map +1 -1
  48. package/dist/e2e/harness/BridgeSetup.js +10 -1
  49. package/dist/e2e/harness/BridgeSetup.js.map +1 -1
  50. package/dist/e2e/harness/ForkIndexer.d.ts.map +1 -1
  51. package/dist/e2e/harness/ForkIndexer.js +1 -0
  52. package/dist/e2e/harness/ForkIndexer.js.map +1 -1
  53. package/dist/e2e/harness/TestHelpers.d.ts.map +1 -1
  54. package/dist/e2e/harness/TestHelpers.js +1 -4
  55. package/dist/e2e/harness/TestHelpers.js.map +1 -1
  56. package/dist/e2e/harness/TestRebalancer.d.ts +1 -1
  57. package/dist/e2e/harness/TestRebalancer.d.ts.map +1 -1
  58. package/dist/e2e/harness/TestRebalancer.js +9 -9
  59. package/dist/e2e/harness/TestRebalancer.js.map +1 -1
  60. package/dist/e2e/minAmount.e2e-test.js +0 -1
  61. package/dist/e2e/minAmount.e2e-test.js.map +1 -1
  62. package/dist/e2e/weighted.e2e-test.js +0 -1
  63. package/dist/e2e/weighted.e2e-test.js.map +1 -1
  64. package/dist/factories/RebalancerContextFactory.d.ts +48 -6
  65. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  66. package/dist/factories/RebalancerContextFactory.js +171 -17
  67. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  68. package/dist/index.d.ts +6 -6
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +2 -2
  71. package/dist/index.js.map +1 -1
  72. package/dist/interfaces/IExternalBridge.d.ts +101 -0
  73. package/dist/interfaces/IExternalBridge.d.ts.map +1 -0
  74. package/dist/interfaces/IExternalBridge.js +2 -0
  75. package/dist/interfaces/IExternalBridge.js.map +1 -0
  76. package/dist/interfaces/IMonitor.d.ts +1 -0
  77. package/dist/interfaces/IMonitor.d.ts.map +1 -1
  78. package/dist/interfaces/IRebalancer.d.ts +25 -25
  79. package/dist/interfaces/IRebalancer.d.ts.map +1 -1
  80. package/dist/interfaces/IStrategy.d.ts +36 -3
  81. package/dist/interfaces/IStrategy.d.ts.map +1 -1
  82. package/dist/interfaces/IStrategy.js +12 -1
  83. package/dist/interfaces/IStrategy.js.map +1 -1
  84. package/dist/metrics/PriceGetter.js +1 -1
  85. package/dist/metrics/PriceGetter.js.map +1 -1
  86. package/dist/metrics/scripts/metrics.d.ts +3 -3
  87. package/dist/monitor/Monitor.d.ts +12 -2
  88. package/dist/monitor/Monitor.d.ts.map +1 -1
  89. package/dist/monitor/Monitor.js +46 -1
  90. package/dist/monitor/Monitor.js.map +1 -1
  91. package/dist/service.js +40 -17
  92. package/dist/service.js.map +1 -1
  93. package/dist/strategy/BaseStrategy.d.ts +12 -6
  94. package/dist/strategy/BaseStrategy.d.ts.map +1 -1
  95. package/dist/strategy/BaseStrategy.js +56 -21
  96. package/dist/strategy/BaseStrategy.js.map +1 -1
  97. package/dist/strategy/CollateralDeficitStrategy.d.ts +1 -1
  98. package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
  99. package/dist/strategy/CollateralDeficitStrategy.js +19 -11
  100. package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
  101. package/dist/strategy/CollateralDeficitStrategy.test.js +135 -2
  102. package/dist/strategy/CollateralDeficitStrategy.test.js.map +1 -1
  103. package/dist/strategy/CompositeStrategy.test.js +13 -0
  104. package/dist/strategy/CompositeStrategy.test.js.map +1 -1
  105. package/dist/strategy/MinAmountStrategy.test.js +4 -0
  106. package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
  107. package/dist/strategy/StrategyFactory.d.ts +2 -1
  108. package/dist/strategy/StrategyFactory.d.ts.map +1 -1
  109. package/dist/strategy/StrategyFactory.js +24 -8
  110. package/dist/strategy/StrategyFactory.js.map +1 -1
  111. package/dist/strategy/WeightedStrategy.test.js +6 -0
  112. package/dist/strategy/WeightedStrategy.test.js.map +1 -1
  113. package/dist/test/helpers.d.ts +8 -7
  114. package/dist/test/helpers.d.ts.map +1 -1
  115. package/dist/test/helpers.js +23 -5
  116. package/dist/test/helpers.js.map +1 -1
  117. package/dist/test/lifiMocks.d.ts +51 -0
  118. package/dist/test/lifiMocks.d.ts.map +1 -0
  119. package/dist/test/lifiMocks.js +130 -0
  120. package/dist/test/lifiMocks.js.map +1 -0
  121. package/dist/tracking/ActionTracker.d.ts +34 -1
  122. package/dist/tracking/ActionTracker.d.ts.map +1 -1
  123. package/dist/tracking/ActionTracker.js +233 -26
  124. package/dist/tracking/ActionTracker.js.map +1 -1
  125. package/dist/tracking/ActionTracker.test.js +380 -19
  126. package/dist/tracking/ActionTracker.test.js.map +1 -1
  127. package/dist/tracking/IActionTracker.d.ts +48 -3
  128. package/dist/tracking/IActionTracker.d.ts.map +1 -1
  129. package/dist/tracking/InflightContextAdapter.d.ts.map +1 -1
  130. package/dist/tracking/InflightContextAdapter.js +24 -7
  131. package/dist/tracking/InflightContextAdapter.js.map +1 -1
  132. package/dist/tracking/InflightContextAdapter.test.js +7 -4
  133. package/dist/tracking/InflightContextAdapter.test.js.map +1 -1
  134. package/dist/tracking/types.d.ts +33 -2
  135. package/dist/tracking/types.d.ts.map +1 -1
  136. package/dist/utils/ExplorerClient.d.ts +3 -1
  137. package/dist/utils/ExplorerClient.d.ts.map +1 -1
  138. package/dist/utils/ExplorerClient.js +16 -8
  139. package/dist/utils/ExplorerClient.js.map +1 -1
  140. package/dist/utils/bridgeUtils.d.ts +27 -4
  141. package/dist/utils/bridgeUtils.d.ts.map +1 -1
  142. package/dist/utils/bridgeUtils.js +38 -0
  143. package/dist/utils/bridgeUtils.js.map +1 -1
  144. package/dist/utils/bridgeUtils.test.js +9 -0
  145. package/dist/utils/bridgeUtils.test.js.map +1 -1
  146. package/dist/utils/gasEstimation.d.ts +65 -0
  147. package/dist/utils/gasEstimation.d.ts.map +1 -0
  148. package/dist/utils/gasEstimation.js +176 -0
  149. package/dist/utils/gasEstimation.js.map +1 -0
  150. package/dist/utils/tokenUtils.d.ts +9 -1
  151. package/dist/utils/tokenUtils.d.ts.map +1 -1
  152. package/dist/utils/tokenUtils.js +11 -0
  153. package/dist/utils/tokenUtils.js.map +1 -1
  154. package/package.json +9 -7
  155. package/src/bridges/LiFiBridge.ts +538 -0
  156. package/src/config/RebalancerConfig.test.ts +162 -0
  157. package/src/config/RebalancerConfig.ts +21 -3
  158. package/src/config/types.ts +147 -10
  159. package/src/core/InventoryRebalancer.test.ts +1721 -0
  160. package/src/core/InventoryRebalancer.ts +1265 -0
  161. package/src/core/Rebalancer.test.ts +84 -30
  162. package/src/core/Rebalancer.ts +144 -23
  163. package/src/core/RebalancerOrchestrator.test.ts +869 -0
  164. package/src/core/RebalancerOrchestrator.ts +146 -95
  165. package/src/core/RebalancerService.test.ts +86 -124
  166. package/src/core/RebalancerService.ts +67 -33
  167. package/src/e2e/collateral-deficit.e2e-test.ts +2 -4
  168. package/src/e2e/composite.e2e-test.ts +5 -5
  169. package/src/e2e/harness/BridgeSetup.ts +28 -1
  170. package/src/e2e/harness/ForkIndexer.ts +1 -0
  171. package/src/e2e/harness/TestHelpers.ts +1 -4
  172. package/src/e2e/harness/TestRebalancer.ts +10 -7
  173. package/src/e2e/minAmount.e2e-test.ts +1 -2
  174. package/src/e2e/weighted.e2e-test.ts +1 -2
  175. package/src/factories/RebalancerContextFactory.ts +294 -24
  176. package/src/index.ts +22 -5
  177. package/src/interfaces/IExternalBridge.ts +115 -0
  178. package/src/interfaces/IMonitor.ts +1 -0
  179. package/src/interfaces/IRebalancer.ts +45 -29
  180. package/src/interfaces/IStrategy.ts +50 -3
  181. package/src/metrics/PriceGetter.ts +1 -1
  182. package/src/monitor/Monitor.ts +81 -2
  183. package/src/service.ts +59 -18
  184. package/src/strategy/BaseStrategy.ts +77 -24
  185. package/src/strategy/CollateralDeficitStrategy.test.ts +181 -4
  186. package/src/strategy/CollateralDeficitStrategy.ts +42 -15
  187. package/src/strategy/CompositeStrategy.test.ts +13 -0
  188. package/src/strategy/MinAmountStrategy.test.ts +4 -0
  189. package/src/strategy/StrategyFactory.ts +33 -6
  190. package/src/strategy/WeightedStrategy.test.ts +6 -0
  191. package/src/test/helpers.ts +39 -14
  192. package/src/test/lifiMocks.ts +174 -0
  193. package/src/tracking/ActionTracker.test.ts +443 -19
  194. package/src/tracking/ActionTracker.ts +339 -28
  195. package/src/tracking/IActionTracker.ts +59 -3
  196. package/src/tracking/InflightContextAdapter.test.ts +7 -4
  197. package/src/tracking/InflightContextAdapter.ts +42 -9
  198. package/src/tracking/types.ts +45 -2
  199. package/src/utils/ExplorerClient.ts +27 -10
  200. package/src/utils/bridgeUtils.test.ts +9 -0
  201. package/src/utils/bridgeUtils.ts +75 -6
  202. package/src/utils/gasEstimation.ts +272 -0
  203. package/src/utils/tokenUtils.ts +12 -0
  204. package/dist/tracking/index.d.ts +0 -7
  205. package/dist/tracking/index.d.ts.map +0 -1
  206. package/dist/tracking/index.js +0 -6
  207. package/dist/tracking/index.js.map +0 -1
  208. package/dist/utils/index.d.ts +0 -5
  209. package/dist/utils/index.d.ts.map +0 -1
  210. package/dist/utils/index.js +0 -5
  211. package/dist/utils/index.js.map +0 -1
  212. package/src/tracking/index.ts +0 -36
  213. package/src/utils/index.ts +0 -4
@@ -0,0 +1,538 @@
1
+ import {
2
+ EVM,
3
+ type RouteExtended,
4
+ convertQuoteToRoute,
5
+ createConfig,
6
+ executeRoute,
7
+ getQuote,
8
+ getStatus,
9
+ config as lifiConfig,
10
+ } from '@lifi/sdk';
11
+ import type { Logger } from 'pino';
12
+ import { type Chain, createWalletClient, http } from 'viem';
13
+ import { privateKeyToAccount } from 'viem/accounts';
14
+ import { arbitrum, base, mainnet, optimism } from 'viem/chains';
15
+
16
+ import type {
17
+ BridgeQuote,
18
+ BridgeQuoteParams,
19
+ BridgeTransferResult,
20
+ BridgeTransferStatus,
21
+ ExternalBridgeConfig,
22
+ IExternalBridge,
23
+ } from '../interfaces/IExternalBridge.js';
24
+
25
+ /**
26
+ * LiFi API base URL for REST endpoints.
27
+ * The SDK doesn't support toAmount quotes, so we use REST API directly.
28
+ */
29
+ const LIFI_API_BASE = 'https://li.quest/v1';
30
+
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
+ /**
67
+ * Known chains for viem - add more as needed.
68
+ * TODO: can we think of a cleaner way to do this?
69
+ */
70
+ const VIEM_CHAINS: Record<number, Chain> = {
71
+ [mainnet.id]: mainnet,
72
+ [arbitrum.id]: arbitrum,
73
+ [base.id]: base,
74
+ [optimism.id]: optimism,
75
+ };
76
+
77
+ /**
78
+ * Get viem chain config by chain ID.
79
+ * Falls back to a minimal chain config if not found.
80
+ */
81
+ function getViemChain(chainId: number, rpcUrl?: string): Chain {
82
+ const chain = VIEM_CHAINS[chainId];
83
+ if (chain) {
84
+ if (rpcUrl) {
85
+ return { ...chain, rpcUrls: { default: { http: [rpcUrl] } } };
86
+ }
87
+ return chain;
88
+ }
89
+
90
+ // Fallback for chains not in our registry
91
+ return {
92
+ id: chainId,
93
+ name: `Chain ${chainId}`,
94
+ nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
95
+ rpcUrls: {
96
+ default: { http: rpcUrl ? [rpcUrl] : [] },
97
+ },
98
+ } as Chain;
99
+ }
100
+
101
+ /**
102
+ * LiFi implementation of IExternalBridge using the official @lifi/sdk.
103
+ *
104
+ * The SDK provides:
105
+ * - Automatic token approvals via executeRoute()
106
+ * - Multi-step route handling (swap → bridge → swap)
107
+ * - Built-in status tracking via getStatus()
108
+ * - Native support for EVM, Solana, and other chains
109
+ *
110
+ * @see https://docs.li.fi/integrate-li.fi-sdk
111
+ */
112
+ export class LiFiBridge implements IExternalBridge {
113
+ private static readonly NATIVE_TOKEN_ADDRESS =
114
+ '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE';
115
+
116
+ readonly externalBridgeId = 'lifi';
117
+ readonly logger: Logger;
118
+ private initialized = false;
119
+ private readonly config: ExternalBridgeConfig;
120
+
121
+ constructor(config: ExternalBridgeConfig, logger: Logger) {
122
+ this.config = config;
123
+ this.logger = logger;
124
+ }
125
+
126
+ getNativeTokenAddress(): string {
127
+ return LiFiBridge.NATIVE_TOKEN_ADDRESS;
128
+ }
129
+
130
+ private initialize(): void {
131
+ if (this.initialized) return;
132
+
133
+ createConfig({
134
+ integrator: this.config.integrator,
135
+ apiKey: this.config.apiKey,
136
+ });
137
+
138
+ this.initialized = true;
139
+ this.logger.info(
140
+ { integrator: this.config.integrator },
141
+ 'LiFi SDK initialized',
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Resolve RPC URL for a given EVM chainId from chain metadata.
147
+ * Iterates metadata to find matching chainId and returns first HTTP RPC URL.
148
+ */
149
+ private getRpcUrlForChainId(chainId: number): string | undefined {
150
+ if (!this.config.chainMetadata) return undefined;
151
+ for (const metadata of Object.values(this.config.chainMetadata)) {
152
+ if (metadata.chainId === chainId && metadata.rpcUrls?.length) {
153
+ return metadata.rpcUrls[0].http;
154
+ }
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ /**
160
+ * Get a quote for bridging tokens.
161
+ * Supports two modes:
162
+ * - fromAmount: "I'm sending X, what do I get?" (uses SDK)
163
+ * - toAmount: "I want X, how much do I send?" (uses REST API)
164
+ *
165
+ * Returns route data ready for execution.
166
+ */
167
+ async quote(params: BridgeQuoteParams): Promise<BridgeQuote> {
168
+ this.initialize();
169
+
170
+ // Validate that exactly one of fromAmount or toAmount is provided
171
+ if (params.fromAmount !== undefined && params.toAmount !== undefined) {
172
+ throw new Error(
173
+ 'Cannot specify both fromAmount and toAmount - provide exactly one',
174
+ );
175
+ }
176
+ if (params.fromAmount === undefined && params.toAmount === undefined) {
177
+ throw new Error('Must specify either fromAmount or toAmount');
178
+ }
179
+
180
+ // Dispatch to appropriate quote method
181
+ if (params.toAmount !== undefined) {
182
+ return this.quoteByReceivingAmount(params);
183
+ } else {
184
+ return this.quoteBySpendingAmount(params);
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Get a quote by specifying the amount to send (standard quote).
190
+ * Uses the LiFi SDK.
191
+ */
192
+ private async quoteBySpendingAmount(
193
+ params: BridgeQuoteParams,
194
+ ): Promise<BridgeQuote> {
195
+ this.logger.debug({ params }, 'Requesting LiFi quote by spending amount');
196
+
197
+ const quote = await getQuote({
198
+ fromChain: params.fromChain,
199
+ toChain: params.toChain,
200
+ fromToken: params.fromToken,
201
+ toToken: params.toToken,
202
+ fromAmount: params.fromAmount!.toString(),
203
+ fromAddress: params.fromAddress,
204
+ toAddress: params.toAddress ?? params.fromAddress,
205
+ slippage: params.slippage ?? this.config.defaultSlippage ?? 0.005,
206
+ // Prefer faster routes for rebalancing
207
+ order: 'RECOMMENDED',
208
+ });
209
+
210
+ const { gasCosts, feeCosts } = this.extractCosts(
211
+ quote as unknown as LiFiQuoteResponse,
212
+ );
213
+
214
+ this.logger.info(
215
+ {
216
+ quoteId: quote.id,
217
+ tool: quote.tool,
218
+ fromAmount: quote.action.fromAmount,
219
+ toAmount: quote.estimate.toAmount,
220
+ toAmountMin: quote.estimate.toAmountMin,
221
+ executionDuration: quote.estimate.executionDuration,
222
+ gasCosts: gasCosts.toString(),
223
+ feeCosts: feeCosts.toString(),
224
+ },
225
+ 'LiFi quote received (fromAmount)',
226
+ );
227
+
228
+ return {
229
+ id: quote.id,
230
+ tool: quote.tool,
231
+ fromAmount: BigInt(quote.action.fromAmount),
232
+ toAmount: BigInt(quote.estimate.toAmount),
233
+ toAmountMin: BigInt(quote.estimate.toAmountMin),
234
+ executionDuration: quote.estimate.executionDuration,
235
+ gasCosts,
236
+ feeCosts,
237
+ route: quote, // Store full quote for conversion to route
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Get a quote by specifying the amount to receive (reverse quote).
243
+ * Uses the LiFi REST API directly since the SDK doesn't support toAmount.
244
+ */
245
+ private async quoteByReceivingAmount(
246
+ params: BridgeQuoteParams,
247
+ ): Promise<BridgeQuote> {
248
+ this.logger.debug({ params }, 'Requesting LiFi quote by receiving amount');
249
+
250
+ const queryParams = new URLSearchParams({
251
+ fromChain: params.fromChain.toString(),
252
+ toChain: params.toChain.toString(),
253
+ fromToken: params.fromToken,
254
+ toToken: params.toToken,
255
+ toAmount: params.toAmount!.toString(),
256
+ fromAddress: params.fromAddress,
257
+ toAddress: params.toAddress ?? params.fromAddress,
258
+ slippage: (params.slippage ?? this.config.defaultSlippage ?? 0.005)
259
+ .toFixed(4)
260
+ .replace(/\.?0+$/, ''),
261
+ order: 'CHEAPEST',
262
+ integrator: this.config.integrator,
263
+ });
264
+
265
+ if (this.config.apiKey) {
266
+ queryParams.set('apiKey', this.config.apiKey);
267
+ }
268
+
269
+ const url = `${LIFI_API_BASE}/quote/toAmount?${queryParams.toString()}`;
270
+ this.logger.debug(
271
+ { url: url.replace(/apiKey=[^&]+/, 'apiKey=***') },
272
+ 'Fetching LiFi toAmount quote',
273
+ );
274
+
275
+ const response = await fetch(url);
276
+ if (!response.ok) {
277
+ const errorBody = await response.text();
278
+ throw new Error(
279
+ `LiFi toAmount quote failed: ${response.status} ${response.statusText} - ${errorBody}`,
280
+ );
281
+ }
282
+
283
+ const quote: LiFiQuoteResponse = await response.json();
284
+ const { gasCosts, feeCosts } = this.extractCosts(quote);
285
+
286
+ this.logger.info(
287
+ {
288
+ quoteId: quote.id,
289
+ tool: quote.tool,
290
+ fromAmount: quote.action.fromAmount,
291
+ toAmount: quote.estimate.toAmount,
292
+ toAmountMin: quote.estimate.toAmountMin,
293
+ executionDuration: quote.estimate.executionDuration,
294
+ gasCosts: gasCosts.toString(),
295
+ feeCosts: feeCosts.toString(),
296
+ },
297
+ 'LiFi quote received (toAmount)',
298
+ );
299
+
300
+ return {
301
+ id: quote.id,
302
+ tool: quote.tool,
303
+ fromAmount: BigInt(quote.action.fromAmount),
304
+ toAmount: BigInt(quote.estimate.toAmount),
305
+ toAmountMin: BigInt(quote.estimate.toAmountMin),
306
+ executionDuration: quote.estimate.executionDuration,
307
+ gasCosts,
308
+ feeCosts,
309
+ route: quote, // Store full quote for conversion to route
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Extract gas and fee costs from a LiFi quote response.
315
+ * - gasCosts: Sum of all gas costs (transaction fees)
316
+ * - feeCosts: Sum of non-included fee costs (protocol fees not deducted from amount)
317
+ */
318
+ private extractCosts(quote: LiFiQuoteResponse): {
319
+ gasCosts: bigint;
320
+ feeCosts: bigint;
321
+ } {
322
+ let gasCosts = 0n;
323
+ let feeCosts = 0n;
324
+
325
+ // Sum up gas costs
326
+ if (quote.estimate.gasCosts) {
327
+ for (const cost of quote.estimate.gasCosts) {
328
+ gasCosts += BigInt(cost.amount);
329
+ }
330
+ }
331
+
332
+ // Sum up non-included fee costs
333
+ // (included fees are already deducted from toAmount, so we only count non-included)
334
+ if (quote.estimate.feeCosts) {
335
+ for (const cost of quote.estimate.feeCosts) {
336
+ if (!cost.included) {
337
+ feeCosts += BigInt(cost.amount);
338
+ }
339
+ }
340
+ }
341
+
342
+ return { gasCosts, feeCosts };
343
+ }
344
+
345
+ /**
346
+ * Execute a bridge transfer using the SDK.
347
+ * Handles approvals, transaction signing, and execution automatically.
348
+ *
349
+ * @param quote - Quote obtained from quote()
350
+ * @param privateKey - Private key hex string (0x-prefixed) for signing the transaction
351
+ */
352
+ async execute(
353
+ quote: BridgeQuote,
354
+ privateKey: string,
355
+ ): Promise<BridgeTransferResult> {
356
+ this.initialize();
357
+
358
+ // Convert quote to route for execution
359
+ const route = convertQuoteToRoute(
360
+ quote.route as Parameters<typeof convertQuoteToRoute>[0],
361
+ );
362
+
363
+ const fromChain = route.fromChainId;
364
+ const toChain = route.toChainId;
365
+
366
+ this.logger.info(
367
+ {
368
+ quoteId: quote.id,
369
+ tool: quote.tool,
370
+ fromChain,
371
+ toChain,
372
+ fromAmount: quote.fromAmount.toString(),
373
+ },
374
+ 'Executing LiFi bridge transfer',
375
+ );
376
+
377
+ // Create viem account and wallet client for the source chain
378
+ const account = privateKeyToAccount(privateKey as `0x${string}`);
379
+ const rpcUrl = this.getRpcUrlForChainId(fromChain);
380
+ const chain = getViemChain(fromChain, rpcUrl);
381
+
382
+ const walletClient = createWalletClient({
383
+ account,
384
+ chain,
385
+ transport: http(rpcUrl),
386
+ });
387
+
388
+ this.logger.debug(
389
+ {
390
+ fromChain,
391
+ chainName: chain.name,
392
+ account: account.address,
393
+ },
394
+ 'Created viem WalletClient for LiFi execution',
395
+ );
396
+
397
+ // Configure LiFi SDK with EVM provider that has our wallet client
398
+ lifiConfig.setProviders([
399
+ EVM({
400
+ getWalletClient: async () => walletClient,
401
+ switchChain: async (requiredChainId: number) => {
402
+ const switchRpcUrl = this.getRpcUrlForChainId(requiredChainId);
403
+ const requiredChain = getViemChain(requiredChainId, switchRpcUrl);
404
+ return createWalletClient({
405
+ account,
406
+ chain: requiredChain,
407
+ transport: http(switchRpcUrl),
408
+ });
409
+ },
410
+ }),
411
+ ]);
412
+
413
+ let txHash: string | undefined;
414
+
415
+ // Execute route with update callbacks
416
+ const executedRoute = await executeRoute(route, {
417
+ // Update callback for route progress
418
+ updateRouteHook: (updatedRoute: RouteExtended) => {
419
+ this.logger.debug(
420
+ { step: updatedRoute.steps[0]?.id },
421
+ 'Route step updated',
422
+ );
423
+
424
+ // Extract txHash from execution if available (RouteExtended has LiFiStepExtended with execution)
425
+ const execution = updatedRoute.steps[0]?.execution;
426
+ if (execution?.process) {
427
+ for (const process of execution.process) {
428
+ if (process.txHash) {
429
+ txHash = process.txHash;
430
+ }
431
+ }
432
+ }
433
+ },
434
+ // Auto-accept rate updates for rebalancing
435
+ acceptExchangeRateUpdateHook: async () => true,
436
+ });
437
+
438
+ // Extract txHash from executed route if not captured in callbacks
439
+ if (!txHash) {
440
+ const execution = executedRoute.steps[0]?.execution;
441
+ if (execution?.process) {
442
+ for (const process of execution.process) {
443
+ if (process.txHash) {
444
+ txHash = process.txHash;
445
+ break;
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ if (!txHash) {
452
+ throw new Error('No transaction hash found in executed route');
453
+ }
454
+
455
+ this.logger.info(
456
+ { txHash, quoteId: quote.id },
457
+ 'LiFi bridge transaction executed',
458
+ );
459
+
460
+ // Extract transfer ID if available (some bridges provide this)
461
+ let transferId: string | undefined;
462
+ const processes = executedRoute.steps[0]?.execution?.process;
463
+ const txInfo = processes?.find((p) => p.txHash === txHash);
464
+ if (txInfo && 'lifiExplorerLink' in txInfo) {
465
+ // Extract transfer ID from explorer link if available
466
+ const link = (txInfo as { lifiExplorerLink?: string }).lifiExplorerLink;
467
+ const match = link?.match(/\/tx\/([^/]+)/);
468
+ if (match) {
469
+ transferId = match[1];
470
+ }
471
+ }
472
+
473
+ return {
474
+ txHash,
475
+ fromChain,
476
+ toChain,
477
+ transferId,
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Get the status of a bridge transfer.
483
+ * Uses SDK's built-in status tracking.
484
+ */
485
+ async getStatus(
486
+ txHash: string,
487
+ fromChain: number,
488
+ toChain: number,
489
+ ): Promise<BridgeTransferStatus> {
490
+ this.initialize();
491
+
492
+ try {
493
+ const status = await getStatus({
494
+ txHash,
495
+ fromChain,
496
+ toChain,
497
+ });
498
+
499
+ switch (status.status) {
500
+ case 'DONE': {
501
+ // receiving can be PendingReceivingInfo (only chainId) or ExtendedTransactionInfo (has txHash, amount)
502
+ const receiving = status.receiving;
503
+ const receivingTxHash =
504
+ receiving && 'txHash' in receiving ? (receiving.txHash ?? '') : '';
505
+ const receivedAmount =
506
+ receiving && 'amount' in receiving
507
+ ? BigInt(receiving.amount ?? '0')
508
+ : BigInt(0);
509
+ return {
510
+ status: 'complete',
511
+ receivingTxHash,
512
+ receivedAmount,
513
+ };
514
+ }
515
+
516
+ case 'FAILED':
517
+ return {
518
+ status: 'failed',
519
+ error: status.substatus,
520
+ };
521
+
522
+ case 'NOT_FOUND':
523
+ case 'INVALID':
524
+ return { status: 'not_found' };
525
+
526
+ case 'PENDING':
527
+ default:
528
+ return {
529
+ status: 'pending',
530
+ substatus: status.substatus,
531
+ };
532
+ }
533
+ } catch (error) {
534
+ this.logger.warn({ txHash, error }, 'Failed to get LiFi status');
535
+ return { status: 'not_found' };
536
+ }
537
+ }
538
+ }
@@ -9,6 +9,9 @@ import { writeYamlOrJson } from '@hyperlane-xyz/utils/fs';
9
9
 
10
10
  import { RebalancerConfig } from './RebalancerConfig.js';
11
11
  import {
12
+ DEFAULT_INTENT_TTL_MS,
13
+ ExecutionType,
14
+ ExternalBridgeType,
12
15
  type RebalancerConfigFileInput,
13
16
  RebalancerMinAmountType,
14
17
  RebalancerStrategyOptions,
@@ -98,6 +101,9 @@ describe('RebalancerConfig', () => {
98
101
  },
99
102
  },
100
103
  ],
104
+ intentTTL: DEFAULT_INTENT_TTL_MS,
105
+ inventorySigner: undefined,
106
+ externalBridges: undefined,
101
107
  });
102
108
  });
103
109
 
@@ -500,6 +506,162 @@ describe('RebalancerConfig', () => {
500
506
  });
501
507
  });
502
508
 
509
+ describe('per-chain bridge configuration', () => {
510
+ const TEST_CONFIG_PATH_BRIDGE = join(tmpdir(), 'rebalancer-bridge-test.yaml');
511
+
512
+ afterEach(() => {
513
+ rmSync(TEST_CONFIG_PATH_BRIDGE, { force: true });
514
+ });
515
+
516
+ it('should accept externalBridge field on chain config when using inventory execution', () => {
517
+ const data: RebalancerConfigFileInput = {
518
+ warpRouteId: 'test-route',
519
+ strategy: [
520
+ {
521
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
522
+ chains: {
523
+ ethereum: {
524
+ weighted: { weight: 50, tolerance: 5 },
525
+ executionType: ExecutionType.Inventory,
526
+ externalBridge: ExternalBridgeType.LiFi,
527
+ },
528
+ arbitrum: {
529
+ weighted: { weight: 50, tolerance: 5 },
530
+ executionType: ExecutionType.Inventory,
531
+ externalBridge: ExternalBridgeType.LiFi,
532
+ },
533
+ },
534
+ },
535
+ ],
536
+ inventorySigner: '0x1234567890123456789012345678901234567890',
537
+ externalBridges: {
538
+ lifi: {
539
+ integrator: 'test-app',
540
+ },
541
+ },
542
+ };
543
+
544
+ writeYamlOrJson(TEST_CONFIG_PATH_BRIDGE, data);
545
+ const config = RebalancerConfig.load(TEST_CONFIG_PATH_BRIDGE);
546
+
547
+ expect(config.strategyConfig[0].chains.ethereum.externalBridge).to.equal(
548
+ 'lifi',
549
+ );
550
+ expect(config.externalBridges?.lifi?.integrator).to.equal('test-app');
551
+ });
552
+
553
+ it('should accept bridges.lifi section with integrator and optional defaultSlippage', () => {
554
+ const data: RebalancerConfigFileInput = {
555
+ warpRouteId: 'test-route',
556
+ strategy: [
557
+ {
558
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
559
+ chains: {
560
+ ethereum: {
561
+ weighted: { weight: 100, tolerance: 5 },
562
+ executionType: ExecutionType.Inventory,
563
+ externalBridge: ExternalBridgeType.LiFi,
564
+ },
565
+ },
566
+ },
567
+ ],
568
+ inventorySigner: '0x1234567890123456789012345678901234567890',
569
+ externalBridges: {
570
+ lifi: {
571
+ integrator: 'my-app',
572
+ defaultSlippage: 0.01,
573
+ },
574
+ },
575
+ };
576
+
577
+ writeYamlOrJson(TEST_CONFIG_PATH_BRIDGE, data);
578
+ const config = RebalancerConfig.load(TEST_CONFIG_PATH_BRIDGE);
579
+
580
+ expect(config.externalBridges?.lifi).to.deep.include({
581
+ integrator: 'my-app',
582
+ defaultSlippage: 0.01,
583
+ });
584
+ });
585
+
586
+ it('should require externalBridges.lifi when executionType is inventory', () => {
587
+ const data = {
588
+ warpRouteId: 'test-route',
589
+ strategy: [
590
+ {
591
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
592
+ chains: {
593
+ ethereum: {
594
+ weighted: { weight: 100, tolerance: 5 },
595
+ executionType: ExecutionType.Inventory,
596
+ },
597
+ },
598
+ },
599
+ ],
600
+ inventorySigner: '0x1234567890123456789012345678901234567890',
601
+ };
602
+
603
+ writeYamlOrJson(TEST_CONFIG_PATH_BRIDGE, data);
604
+
605
+ expect(() => RebalancerConfig.load(TEST_CONFIG_PATH_BRIDGE)).to.throw(
606
+ /externalBridges\.lifi.*required/i,
607
+ );
608
+ });
609
+
610
+ it('should require externalBridges.lifi when externalBridge is lifi', () => {
611
+ const data = {
612
+ warpRouteId: 'test-route',
613
+ strategy: [
614
+ {
615
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
616
+ chains: {
617
+ ethereum: {
618
+ weighted: { weight: 100, tolerance: 5 },
619
+ executionType: ExecutionType.Inventory,
620
+ externalBridge: ExternalBridgeType.LiFi,
621
+ },
622
+ },
623
+ },
624
+ ],
625
+ inventorySigner: '0x1234567890123456789012345678901234567890',
626
+ };
627
+
628
+ writeYamlOrJson(TEST_CONFIG_PATH_BRIDGE, data);
629
+
630
+ expect(() => RebalancerConfig.load(TEST_CONFIG_PATH_BRIDGE)).to.throw(
631
+ /externalBridges\.lifi.*required|lifi.*not configured/i,
632
+ );
633
+ });
634
+
635
+ it('should require externalBridge field when executionType is inventory', () => {
636
+ const data = {
637
+ warpRouteId: 'test-route',
638
+ strategy: [
639
+ {
640
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
641
+ chains: {
642
+ ethereum: {
643
+ weighted: { weight: 100, tolerance: 5 },
644
+ executionType: ExecutionType.Inventory,
645
+ },
646
+ },
647
+ },
648
+ ],
649
+ inventorySigner: '0x1234567890123456789012345678901234567890',
650
+ externalBridges: {
651
+ lifi: {
652
+ integrator: 'test-app',
653
+ },
654
+ },
655
+ };
656
+
657
+ writeYamlOrJson(TEST_CONFIG_PATH_BRIDGE, data);
658
+
659
+ expect(() => RebalancerConfig.load(TEST_CONFIG_PATH_BRIDGE)).to.throw(
660
+ /ethereum.*inventory execution.*externalBridge/i,
661
+ );
662
+ });
663
+ });
664
+
503
665
  describe('getAllBridges', () => {
504
666
  const BRIDGE_A = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
505
667
  const BRIDGE_B = '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';