@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,892 @@
1
+ import { Wallet } from 'ethers';
2
+ import { HyperlaneCore, TOKEN_COLLATERALIZED_STANDARDS, } from '@hyperlane-xyz/sdk';
3
+ import { assert } from '@hyperlane-xyz/utils';
4
+ import { MIN_VIABLE_COST_MULTIPLIER, calculateTransferCosts, } from '../utils/gasEstimation.js';
5
+ import { isNativeTokenStandard } from '../utils/tokenUtils.js';
6
+ /**
7
+ * Buffer percentage to add when bridging inventory.
8
+ * Bridges (amount * (100 + BRIDGE_BUFFER_PERCENT)) / 100 to account for slippage.
9
+ */
10
+ const BRIDGE_BUFFER_PERCENT = 5n;
11
+ /**
12
+ * Multiplier applied to LiFi's quoted gas costs.
13
+ * LiFi consistently underestimates gas, and gas prices can spike significantly
14
+ * between quote and execution. Using 20x provides headroom for volatility
15
+ * (historically LiFi underestimates by ~14x).
16
+ */
17
+ const GAS_COST_MULTIPLIER = 20n;
18
+ /**
19
+ * Maximum percentage of inventory that gas costs can consume for a bridge to be viable.
20
+ * If gas exceeds this threshold, the bridge is not economically worthwhile.
21
+ */
22
+ const MAX_GAS_PERCENT_THRESHOLD = 10n;
23
+ /**
24
+ * Executes inventory-based rebalances for chains that don't support MovableCollateralRouter.
25
+ *
26
+ * IMPORTANT: transferRemote ADDS collateral to the ORIGIN chain (where it's called FROM).
27
+ * So for a strategy route "base (surplus) → arbitrum (deficit)", we must:
28
+ * 1. Ensure inventory is available on the DESTINATION (deficit) chain - arbitrum
29
+ * 2. Call transferRemote FROM arbitrum TO base
30
+ * 3. This ADDS collateral to arbitrum (filling deficit) and releases from base (has surplus)
31
+ *
32
+ * The flow is:
33
+ * 1. Check if inventory is available on the destination (deficit) chain
34
+ * 2. If available, execute transferRemote from destination to origin (swapped direction)
35
+ * 3. If not available, bridge inventory to destination via LiFi, then execute transferRemote
36
+ *
37
+ * Actions created:
38
+ * - `inventory_movement`: LiFi bridge to move inventory to deficit chain
39
+ * - `inventory_deposit`: transferRemote to deposit collateral on deficit chain
40
+ */
41
+ export class InventoryRebalancer {
42
+ rebalancerType = 'inventory';
43
+ logger;
44
+ config;
45
+ actionTracker;
46
+ externalBridgeRegistry;
47
+ warpCore;
48
+ multiProvider;
49
+ /**
50
+ * Internal balance storage for inventory tracking.
51
+ * Updated via setInventoryBalances() before each rebalance cycle.
52
+ */
53
+ inventoryBalances = new Map();
54
+ /**
55
+ * Tracks inventory consumed during the current execution cycle.
56
+ * Cleared at the start of each execute() call.
57
+ * Used to prevent over-execution when multiple routes withdraw from the same chain.
58
+ */
59
+ consumedInventory = new Map();
60
+ constructor(config, actionTracker, externalBridgeRegistry, warpCore, multiProvider, logger) {
61
+ this.config = config;
62
+ this.actionTracker = actionTracker;
63
+ this.externalBridgeRegistry = externalBridgeRegistry;
64
+ this.warpCore = warpCore;
65
+ this.multiProvider = multiProvider;
66
+ this.logger = logger;
67
+ // Validate that all tokens are collateral-backed
68
+ // Synthetic tokens cannot be used with inventory rebalancing because:
69
+ // - transferRemote on synthetics mints new tokens (doesn't transfer collateral)
70
+ // - There's no collateral to deposit/withdraw
71
+ this.validateCollateralBackedTokens();
72
+ this.logger.info({ inventorySigner: config.inventorySigner }, 'InventoryRebalancer initialized');
73
+ }
74
+ /**
75
+ * Get bridge instance by type from registry.
76
+ * Throws if bridge type not found.
77
+ */
78
+ getExternalBridge(type) {
79
+ const externalBridge = this.externalBridgeRegistry[type];
80
+ if (!externalBridge) {
81
+ throw new Error(`Bridge type '${type}' not found in registry`);
82
+ }
83
+ return externalBridge;
84
+ }
85
+ getNativeTokenAddress(bridgeType) {
86
+ const bridge = this.getExternalBridge(bridgeType);
87
+ const addr = bridge.getNativeTokenAddress?.();
88
+ if (!addr) {
89
+ throw new Error(`Bridge '${bridge.externalBridgeId}' does not support getNativeTokenAddress()`);
90
+ }
91
+ return addr;
92
+ }
93
+ /**
94
+ * Validate that tokens on inventory chains are collateral-backed.
95
+ * Only checks tokens for chains configured with inventory-based rebalancing.
96
+ * Throws an error if any synthetic tokens are found on inventory chains.
97
+ */
98
+ validateCollateralBackedTokens() {
99
+ const inventoryChainSet = new Set(this.config.inventoryChains);
100
+ for (const token of this.warpCore.tokens) {
101
+ // Only validate tokens for chains configured for inventory rebalancing
102
+ if (!inventoryChainSet.has(token.chainName)) {
103
+ continue;
104
+ }
105
+ if (!TOKEN_COLLATERALIZED_STANDARDS.includes(token.standard)) {
106
+ throw new Error(`InventoryRebalancer cannot be used with synthetic token on chain "${token.chainName}". ` +
107
+ `Token standard "${token.standard}" is not collateral-backed. ` +
108
+ `Only collateral-backed standards are supported: ${TOKEN_COLLATERALIZED_STANDARDS.join(', ')}`);
109
+ }
110
+ }
111
+ }
112
+ /**
113
+ * Get the token for a specific chain from WarpCore.
114
+ */
115
+ getTokenForChain(chainName) {
116
+ return this.warpCore.tokens.find((t) => t.chainName === chainName);
117
+ }
118
+ /**
119
+ * Set inventory balances from external source.
120
+ * Called before each rebalance cycle to update internal state.
121
+ */
122
+ setInventoryBalances(balances) {
123
+ this.inventoryBalances = new Map(Object.entries(balances));
124
+ this.logger.debug({
125
+ chains: Array.from(this.inventoryBalances.keys()),
126
+ balances: Object.fromEntries(Array.from(this.inventoryBalances.entries()).map(([chain, balance]) => [chain, balance.toString()])),
127
+ }, 'Updated inventory balances');
128
+ }
129
+ /**
130
+ * Get available inventory for a chain.
131
+ * Returns 0n for unknown chains.
132
+ */
133
+ getAvailableInventory(chain) {
134
+ return this.inventoryBalances.get(chain) ?? 0n;
135
+ }
136
+ /**
137
+ * Get all inventory balances.
138
+ */
139
+ getBalances() {
140
+ return this.inventoryBalances;
141
+ }
142
+ /**
143
+ * Calculate total inventory across all chains, excluding specified chains.
144
+ */
145
+ getTotalInventory(excludeChains) {
146
+ const excludeSet = new Set(excludeChains);
147
+ let total = 0n;
148
+ for (const [chain, balance] of this.inventoryBalances) {
149
+ if (!excludeSet.has(chain)) {
150
+ total += balance;
151
+ }
152
+ }
153
+ return total;
154
+ }
155
+ /**
156
+ * Get the effective available inventory for a chain, accounting for
157
+ * inventory already consumed during this execution cycle.
158
+ *
159
+ * This prevents over-execution when multiple routes withdraw from the same chain.
160
+ *
161
+ * @param chain - The chain to check inventory for
162
+ * @returns Effective available inventory (cached - consumed)
163
+ */
164
+ getEffectiveAvailableInventory(chain) {
165
+ const cached = this.getAvailableInventory(chain);
166
+ const consumed = this.consumedInventory.get(chain) ?? 0n;
167
+ const effective = cached > consumed ? cached - consumed : 0n;
168
+ if (consumed > 0n) {
169
+ this.logger.debug({
170
+ chain,
171
+ cachedInventory: cached.toString(),
172
+ consumedThisCycle: consumed.toString(),
173
+ effectiveInventory: effective.toString(),
174
+ }, 'Calculated effective inventory after prior executions');
175
+ }
176
+ return effective;
177
+ }
178
+ /**
179
+ * Execute inventory-based rebalances for the given routes.
180
+ *
181
+ * Single-intent architecture:
182
+ * 1. Check for existing in_progress intent
183
+ * 2. If exists, continue existing intent (ignores new routes)
184
+ * 3. If not, take only the FIRST route and create a single intent
185
+ */
186
+ async rebalance(routes) {
187
+ this.consumedInventory.clear();
188
+ // 1. Check for existing in_progress intent
189
+ const activeIntent = await this.getActiveInventoryIntent();
190
+ if (activeIntent) {
191
+ if (activeIntent.hasInflightDeposit) {
192
+ this.logger.info({
193
+ intentId: activeIntent.intent.id,
194
+ remaining: activeIntent.remaining.toString(),
195
+ }, 'Active intent has in-flight deposit, waiting for delivery before continuing');
196
+ return [];
197
+ }
198
+ // Continue existing intent, ignore new routes
199
+ this.logger.info({
200
+ intentId: activeIntent.intent.id,
201
+ remaining: activeIntent.remaining.toString(),
202
+ newRoutesIgnored: routes.length,
203
+ }, 'Continuing existing intent, ignoring new routes');
204
+ return this.continueIntent(activeIntent);
205
+ }
206
+ // 2. No existing intent - take first route only
207
+ if (routes.length === 0)
208
+ return [];
209
+ const route = routes[0];
210
+ if (routes.length > 1) {
211
+ this.logger.info({
212
+ selectedRoute: `${route.origin} → ${route.destination}`,
213
+ discardedCount: routes.length - 1,
214
+ }, 'Taking first route only, discarding others');
215
+ }
216
+ // 3. Create intent and execute
217
+ const intent = await this.actionTracker.createRebalanceIntent({
218
+ origin: this.multiProvider.getDomainId(route.origin),
219
+ destination: this.multiProvider.getDomainId(route.destination),
220
+ amount: route.amount,
221
+ executionMethod: 'inventory',
222
+ externalBridge: route.externalBridge,
223
+ });
224
+ this.logger.debug({
225
+ intentId: intent.id,
226
+ origin: route.origin,
227
+ destination: route.destination,
228
+ amount: route.amount.toString(),
229
+ }, 'Created new inventory rebalance intent');
230
+ try {
231
+ const result = await this.executeRoute(route, intent);
232
+ // Update consumed inventory on success
233
+ if (result.success && result.amountSent) {
234
+ const current = this.consumedInventory.get(route.destination) ?? 0n;
235
+ this.consumedInventory.set(route.destination, current + result.amountSent);
236
+ }
237
+ return [result];
238
+ }
239
+ catch (error) {
240
+ this.logger.error({
241
+ route,
242
+ intentId: intent.id,
243
+ error: error.message,
244
+ }, 'Failed to execute inventory route');
245
+ return [
246
+ {
247
+ route,
248
+ success: false,
249
+ error: error.message,
250
+ },
251
+ ];
252
+ }
253
+ }
254
+ /**
255
+ * Get the single active inventory intent (if any).
256
+ * Returns null if no in_progress inventory intent exists.
257
+ */
258
+ async getActiveInventoryIntent() {
259
+ const partialIntents = await this.actionTracker.getPartiallyFulfilledInventoryIntents();
260
+ return partialIntents.length > 0 ? partialIntents[0] : null;
261
+ }
262
+ /**
263
+ * Continue execution of an existing partial intent.
264
+ * Uses the pre-computed remaining amount from PartialInventoryIntent.
265
+ */
266
+ async continueIntent(partial) {
267
+ const { intent, remaining } = partial;
268
+ const route = {
269
+ origin: this.multiProvider.getChainName(intent.origin),
270
+ destination: this.multiProvider.getChainName(intent.destination),
271
+ amount: remaining,
272
+ executionType: 'inventory',
273
+ externalBridge: intent.externalBridge,
274
+ };
275
+ this.logger.info({
276
+ intentId: intent.id,
277
+ origin: route.origin,
278
+ destination: route.destination,
279
+ remaining: remaining.toString(),
280
+ completed: partial.completedAmount.toString(),
281
+ total: intent.amount.toString(),
282
+ }, 'Continuing partial inventory intent');
283
+ // Warn if intent never started - indicates previous execution attempt failed
284
+ // without creating any actions (e.g., all bridges failed viability check)
285
+ if (intent.status === 'not_started') {
286
+ this.logger.warn({
287
+ intentId: intent.id,
288
+ origin: route.origin,
289
+ destination: route.destination,
290
+ }, 'Retrying intent that never started - previous execution attempt failed without creating any actions');
291
+ }
292
+ try {
293
+ const result = await this.executeRoute(route, intent);
294
+ // Update consumed inventory on success
295
+ if (result.success && result.amountSent) {
296
+ const current = this.consumedInventory.get(route.destination) ?? 0n;
297
+ this.consumedInventory.set(route.destination, current + result.amountSent);
298
+ }
299
+ return [result];
300
+ }
301
+ catch (error) {
302
+ this.logger.error({
303
+ route,
304
+ intentId: intent.id,
305
+ error: error.message,
306
+ }, 'Failed to continue partial inventory intent');
307
+ return [
308
+ {
309
+ route,
310
+ success: false,
311
+ error: error.message,
312
+ },
313
+ ];
314
+ }
315
+ }
316
+ /**
317
+ * Execute a single inventory route.
318
+ *
319
+ * Strategy provides: origin (surplus) → destination (deficit)
320
+ * "Move collateral FROM origin TO destination"
321
+ *
322
+ * IMPORTANT: transferRemote ADDS collateral to the chain it's called FROM.
323
+ * So to fill the deficit on destination, we must:
324
+ * - Call transferRemote FROM destination TO origin (SWAPPED direction)
325
+ * - This ADDS to destination (deficit filled!) and RELEASES from origin (has surplus)
326
+ *
327
+ * Execution flow:
328
+ * 1. Check inventory on DESTINATION (deficit chain) - need funds there to call transferRemote
329
+ * 2. If low, LiFi bridge TO destination
330
+ * 3. Call transferRemote FROM destination TO origin (swapped)
331
+ */
332
+ async executeRoute(route, intent) {
333
+ const { origin, destination, amount } = route;
334
+ this.logger.info({
335
+ strategyRoute: `${origin} (surplus) → ${destination} (deficit)`,
336
+ executionRoute: `transferRemote FROM ${destination} TO ${origin}`,
337
+ amount: amount.toString(),
338
+ intentId: intent.id,
339
+ }, 'Executing inventory route');
340
+ // Check available inventory on the DESTINATION (deficit) chain
341
+ // We need inventory here because transferRemote is called FROM this chain
342
+ const availableInventory = this.getEffectiveAvailableInventory(destination);
343
+ this.logger.info({
344
+ checkingChain: destination,
345
+ availableInventory: availableInventory.toString(),
346
+ availableInventoryEth: (Number(availableInventory) / 1e18).toFixed(6),
347
+ requiredAmount: amount.toString(),
348
+ requiredAmountEth: (Number(amount) / 1e18).toFixed(6),
349
+ }, 'Checking effective inventory on destination (deficit) chain');
350
+ // Calculate transfer costs including max transferable and min viable amounts
351
+ // transferRemote is called FROM destination TO origin (swapped direction)
352
+ const costs = await calculateTransferCosts(destination, // FROM chain (where transferRemote is called)
353
+ origin, // TO chain (where Hyperlane message goes)
354
+ availableInventory, amount, this.multiProvider, this.warpCore.multiProvider, this.getTokenForChain.bind(this), this.config.inventorySigner, isNativeTokenStandard, this.logger);
355
+ const { maxTransferable, minViableTransfer } = costs;
356
+ // Calculate total inventory across all chains
357
+ // Note: consumedInventory tracking is handled separately within this cycle
358
+ const totalInventory = this.getTotalInventory([]);
359
+ this.logger.info({
360
+ fromChain: destination,
361
+ toChain: origin,
362
+ availableInventoryEth: (Number(availableInventory) / 1e18).toFixed(6),
363
+ requestedAmountEth: (Number(amount) / 1e18).toFixed(6),
364
+ maxTransferableEth: (Number(maxTransferable) / 1e18).toFixed(6),
365
+ minViableTransferEth: (Number(minViableTransfer) / 1e18).toFixed(6),
366
+ totalInventoryEth: (Number(totalInventory) / 1e18).toFixed(6),
367
+ canFullyFulfill: maxTransferable >= amount,
368
+ canPartialFulfill: maxTransferable >= minViableTransfer,
369
+ }, 'Calculated max transferable amount with cost-based threshold');
370
+ // Early exit: If remaining amount is below minViableTransfer, complete the intent
371
+ // This prevents infinite loops when the remaining amount is too small to economically bridge
372
+ if (amount < minViableTransfer) {
373
+ this.logger.info({
374
+ intentId: intent.id,
375
+ amount: amount.toString(),
376
+ minViableTransfer: minViableTransfer.toString(),
377
+ }, 'Remaining amount below minViableTransfer, completing intent with acceptable loss');
378
+ await this.actionTracker.completeRebalanceIntent(intent.id);
379
+ return {
380
+ route,
381
+ success: true,
382
+ reason: 'completed_with_acceptable_loss',
383
+ };
384
+ }
385
+ // Swap the route for executeTransferRemote: destination → origin
386
+ // This ensures transferRemote is called FROM destination, ADDING collateral there
387
+ const swappedRoute = {
388
+ ...route,
389
+ origin: destination, // transferRemote called FROM here
390
+ destination: origin, // Hyperlane message goes TO here
391
+ };
392
+ if (maxTransferable >= amount) {
393
+ // Sufficient inventory on destination - execute transferRemote directly
394
+ const result = await this.executeTransferRemote(swappedRoute, intent, costs.gasQuote);
395
+ // Return original strategy route in result (not the swapped execution route)
396
+ return { ...result, route };
397
+ }
398
+ else if (maxTransferable > 0n && maxTransferable >= minViableTransfer) {
399
+ // Partial transfer: Transfer available inventory when economically viable
400
+ const partialSwappedRoute = {
401
+ ...swappedRoute,
402
+ amount: maxTransferable,
403
+ };
404
+ const result = await this.executeTransferRemote(partialSwappedRoute, intent, costs.gasQuote);
405
+ this.logger.info({
406
+ intentId: intent.id,
407
+ partialAmount: maxTransferable.toString(),
408
+ requestedAmount: amount.toString(),
409
+ remainingAmount: (amount - maxTransferable).toString(),
410
+ }, 'Executed partial inventory deposit, remaining will be handled in future cycles');
411
+ // Return original strategy route in result (not the swapped execution route)
412
+ return { ...result, route };
413
+ }
414
+ else {
415
+ // Inventory below cost-based threshold - trigger ExternalBridge movement TO destination chain
416
+ this.logger.info({
417
+ targetChain: destination,
418
+ maxTransferable: maxTransferable.toString(),
419
+ minViableTransfer: minViableTransfer.toString(),
420
+ costMultiplier: MIN_VIABLE_COST_MULTIPLIER.toString(),
421
+ intentId: intent.id,
422
+ }, 'Inventory below cost-based threshold on destination, triggering LiFi movement');
423
+ // Get all available source chains with raw inventory
424
+ const allSources = this.selectAllSourceChains(destination);
425
+ if (allSources.length === 0) {
426
+ this.logger.warn({
427
+ origin,
428
+ destination,
429
+ amount: amount.toString(),
430
+ intentId: intent.id,
431
+ }, 'No inventory available on any monitored chain');
432
+ return {
433
+ route,
434
+ success: false,
435
+ error: 'No inventory available on any monitored chain',
436
+ };
437
+ }
438
+ // NEW: Calculate max viable amount for each source chain
439
+ // This uses the quote API to determine gas costs upfront
440
+ const viableSources = [];
441
+ for (const source of allSources) {
442
+ const maxViable = await this.calculateMaxViableBridgeAmount(source.chain, destination, source.availableAmount, route.externalBridge);
443
+ if (maxViable > 0n) {
444
+ viableSources.push({ chain: source.chain, maxViable });
445
+ }
446
+ }
447
+ // Sort by max viable descending (bridge from largest sources first)
448
+ viableSources.sort((a, b) => (a.maxViable > b.maxViable ? -1 : 1));
449
+ if (viableSources.length === 0) {
450
+ this.logger.warn({
451
+ targetChain: destination,
452
+ sourcesChecked: allSources.length,
453
+ intentId: intent.id,
454
+ }, 'No viable bridge sources - all chains have insufficient inventory or high gas costs');
455
+ return {
456
+ route,
457
+ success: false,
458
+ error: 'No viable bridge sources available',
459
+ };
460
+ }
461
+ // Create bridge plans using VIABLE amounts (gas already accounted for)
462
+ const targetWithBuffer = ((amount + costs.totalCost) * (100n + BRIDGE_BUFFER_PERCENT)) / 100n;
463
+ const bridgePlans = [];
464
+ let totalPlanned = 0n;
465
+ for (const source of viableSources) {
466
+ if (totalPlanned >= targetWithBuffer)
467
+ break;
468
+ const remaining = targetWithBuffer - totalPlanned;
469
+ const amountFromSource = source.maxViable >= remaining ? remaining : source.maxViable; // Already gas-adjusted!
470
+ bridgePlans.push({
471
+ chain: source.chain,
472
+ amount: amountFromSource,
473
+ });
474
+ totalPlanned += amountFromSource;
475
+ }
476
+ this.logger.info({
477
+ targetChain: destination,
478
+ viableSources: viableSources.map((s) => ({
479
+ chain: s.chain,
480
+ maxViable: s.maxViable.toString(),
481
+ maxViableEth: (Number(s.maxViable) / 1e18).toFixed(6),
482
+ })),
483
+ bridgePlans: bridgePlans.map((p) => ({
484
+ chain: p.chain,
485
+ amount: p.amount.toString(),
486
+ amountEth: (Number(p.amount) / 1e18).toFixed(6),
487
+ })),
488
+ totalPlanned: totalPlanned.toString(),
489
+ targetWithBuffer: targetWithBuffer.toString(),
490
+ intentId: intent.id,
491
+ }, 'Created bridge plans using gas-adjusted viable amounts');
492
+ // Execute all bridges in parallel
493
+ const bridgeResults = await Promise.allSettled(bridgePlans.map((plan) => this.executeInventoryMovement(plan.chain, destination, plan.amount, intent, route.externalBridge)));
494
+ // Process results
495
+ let successCount = 0;
496
+ let totalBridged = 0n;
497
+ const failedErrors = [];
498
+ for (let i = 0; i < bridgeResults.length; i++) {
499
+ const result = bridgeResults[i];
500
+ const plan = bridgePlans[i];
501
+ if (result.status === 'fulfilled' && result.value.success) {
502
+ successCount++;
503
+ totalBridged += plan.amount;
504
+ this.logger.info({
505
+ sourceChain: plan.chain,
506
+ amount: plan.amount.toString(),
507
+ txHash: result.value.txHash,
508
+ }, 'Inventory movement succeeded');
509
+ }
510
+ else {
511
+ const error = result.status === 'rejected'
512
+ ? result.reason?.message
513
+ : result.value.error;
514
+ if (error) {
515
+ failedErrors.push(`${plan.chain}: ${error}`);
516
+ }
517
+ this.logger.warn({
518
+ sourceChain: plan.chain,
519
+ amount: plan.amount.toString(),
520
+ error,
521
+ }, 'Inventory movement failed');
522
+ }
523
+ }
524
+ if (successCount === 0) {
525
+ const errorDetails = failedErrors.length > 0 ? ` (${failedErrors.join('; ')})` : '';
526
+ return {
527
+ route,
528
+ success: false,
529
+ error: `All inventory movements failed${errorDetails}`,
530
+ };
531
+ }
532
+ this.logger.info({
533
+ targetChain: destination,
534
+ successCount,
535
+ totalBridged: totalBridged.toString(),
536
+ targetAmount: amount.toString(),
537
+ intentId: intent.id,
538
+ }, 'Parallel inventory movements completed, transferRemote will execute after bridges complete');
539
+ return { route, success: true };
540
+ }
541
+ }
542
+ /**
543
+ * Execute a transferRemote to deposit collateral.
544
+ *
545
+ * IMPORTANT: The route passed here has SWAPPED direction from the strategy route.
546
+ * - route.origin = the deficit chain (where transferRemote is called FROM)
547
+ * - route.destination = the surplus chain (where Hyperlane message goes TO)
548
+ *
549
+ * transferRemote mechanics:
550
+ * - Calls _transferFromSender() which ADDS collateral to route.origin
551
+ * - Sends Hyperlane message to route.destination to RELEASE collateral
552
+ *
553
+ * @param route - The transfer route (swapped direction)
554
+ * @param intent - The rebalance intent being executed
555
+ * @param gasQuote - Pre-calculated gas quote from calculateTransferCosts
556
+ */
557
+ async executeTransferRemote(route, intent, gasQuote) {
558
+ const { origin, destination, amount } = route;
559
+ const originToken = this.getTokenForChain(origin);
560
+ if (!originToken) {
561
+ throw new Error(`No token found for origin chain: ${origin}`);
562
+ }
563
+ const destinationDomain = this.multiProvider.getDomainId(destination);
564
+ // Get the hyperlane adapter for the token
565
+ const adapter = originToken.getHypAdapter(this.warpCore.multiProvider);
566
+ this.logger.debug({
567
+ origin,
568
+ destination,
569
+ amount: amount.toString(),
570
+ gasQuote: {
571
+ igpQuote: gasQuote.igpQuote.amount.toString(),
572
+ tokenFeeQuote: gasQuote.tokenFeeQuote?.amount?.toString() ?? 'none',
573
+ },
574
+ }, 'Using pre-calculated gas quote for transferRemote');
575
+ // Populate the transferRemote transaction
576
+ const populatedTx = await adapter.populateTransferRemoteTx({
577
+ destination: destinationDomain,
578
+ recipient: this.config.inventorySigner,
579
+ weiAmountOrId: amount,
580
+ interchainGas: gasQuote,
581
+ });
582
+ // Send the transaction using inventory MultiProvider if available
583
+ this.logger.info({
584
+ origin,
585
+ destination,
586
+ amount: amount.toString(),
587
+ intentId: intent.id,
588
+ }, 'Sending transferRemote transaction');
589
+ // Use inventoryMultiProvider if available, otherwise fall back to multiProvider
590
+ const signingProvider = this.config.inventoryMultiProvider ?? this.multiProvider;
591
+ // Get reorgPeriod for confirmation waiting
592
+ const reorgPeriod = this.multiProvider.getChainMetadata(origin).blocks?.reorgPeriod ?? 32;
593
+ // Wait for reorgPeriod confirmations via SDK to ensure Monitor sees balance changes
594
+ const receipt = await signingProvider.sendTransaction(origin, populatedTx, {
595
+ waitConfirmations: reorgPeriod,
596
+ });
597
+ // Extract messageId from the transaction receipt logs
598
+ const dispatchedMessages = HyperlaneCore.getDispatchedMessages(receipt);
599
+ const messageId = dispatchedMessages[0]?.id;
600
+ if (!messageId) {
601
+ this.logger.warn({
602
+ origin,
603
+ destination,
604
+ txHash: receipt.transactionHash,
605
+ intentId: intent.id,
606
+ }, 'TransferRemote transaction sent but no messageId found in logs');
607
+ }
608
+ this.logger.info({
609
+ origin,
610
+ destination,
611
+ txHash: receipt.transactionHash,
612
+ messageId,
613
+ intentId: intent.id,
614
+ }, 'TransferRemote transaction confirmed');
615
+ // Create the inventory_deposit action with messageId for tracking
616
+ await this.actionTracker.createRebalanceAction({
617
+ intentId: intent.id,
618
+ origin: this.multiProvider.getDomainId(origin),
619
+ destination: destinationDomain,
620
+ amount,
621
+ type: 'inventory_deposit',
622
+ txHash: receipt.transactionHash,
623
+ messageId,
624
+ });
625
+ return {
626
+ route,
627
+ success: true,
628
+ amountSent: amount,
629
+ };
630
+ }
631
+ /**
632
+ * Select all source chains with available inventory for bridging.
633
+ * Returns sources sorted by available amount (highest first).
634
+ */
635
+ selectAllSourceChains(targetChain) {
636
+ const balances = this.getBalances();
637
+ const sources = [];
638
+ for (const [chainName, balance] of balances) {
639
+ if (chainName === targetChain)
640
+ continue;
641
+ const consumed = this.consumedInventory.get(chainName) ?? 0n;
642
+ const effectiveAvailable = balance > consumed ? balance - consumed : 0n;
643
+ if (effectiveAvailable > 0n) {
644
+ sources.push({
645
+ chain: chainName,
646
+ availableAmount: effectiveAvailable,
647
+ });
648
+ }
649
+ }
650
+ // Sort by available amount descending (bridge from largest sources first)
651
+ return sources.sort((a, b) => a.availableAmount > b.availableAmount ? -1 : 1);
652
+ }
653
+ /**
654
+ * Calculate the maximum amount that can be bridged from a source chain.
655
+ * Uses LiFi quote to determine gas costs, applies 20x multiplier buffer.
656
+ * Returns 0 if gas exceeds 10% of inventory (not economically viable).
657
+ *
658
+ * This is the key method for the gas-aware planning approach:
659
+ * - Gets a quote for the full raw inventory to determine actual gas costs
660
+ * - Applies conservative 20x buffer (LiFi underestimates by ~14x historically)
661
+ * - Returns 0 if gas > 10% of inventory (not worth bridging)
662
+ * - Returns inventory - estimatedGas if viable
663
+ *
664
+ * @param sourceChain - Chain to bridge from
665
+ * @param targetChain - Chain to bridge to
666
+ * @param rawInventory - Raw available inventory on source chain
667
+ * @param externalBridgeType - External bridge type to use
668
+ * @returns Maximum viable bridge amount (0 if not viable)
669
+ */
670
+ async calculateMaxViableBridgeAmount(sourceChain, targetChain, rawInventory, externalBridgeType) {
671
+ const sourceToken = this.getTokenForChain(sourceChain);
672
+ const targetToken = this.getTokenForChain(targetChain);
673
+ if (!sourceToken || !targetToken)
674
+ return 0n;
675
+ // Only applies to native tokens (need gas from same balance)
676
+ if (!isNativeTokenStandard(sourceToken.standard)) {
677
+ return rawInventory; // ERC20s don't compete with gas
678
+ }
679
+ // Convert HypNative token addresses to LiFi's native ETH representation
680
+ const fromTokenAddress = this.getNativeTokenAddress(externalBridgeType);
681
+ const toTokenAddress = isNativeTokenStandard(targetToken.standard)
682
+ ? this.getNativeTokenAddress(externalBridgeType)
683
+ : targetToken.addressOrDenom;
684
+ const sourceChainId = Number(this.multiProvider.getChainId(sourceChain));
685
+ const targetChainId = Number(this.multiProvider.getChainId(targetChain));
686
+ try {
687
+ const exteralBridge = this.getExternalBridge(externalBridgeType);
688
+ const quote = await exteralBridge.quote({
689
+ fromChain: sourceChainId,
690
+ toChain: targetChainId,
691
+ fromToken: fromTokenAddress,
692
+ toToken: toTokenAddress,
693
+ fromAmount: rawInventory,
694
+ fromAddress: this.config.inventorySigner,
695
+ toAddress: this.config.inventorySigner,
696
+ });
697
+ // Apply 20x multiplier on quoted gas (LiFi underestimates by ~14x)
698
+ const estimatedGas = quote.gasCosts * GAS_COST_MULTIPLIER;
699
+ // Viability check: gas should not exceed 10% of inventory
700
+ const maxGasThreshold = rawInventory / MAX_GAS_PERCENT_THRESHOLD;
701
+ if (estimatedGas > maxGasThreshold) {
702
+ this.logger.info({
703
+ sourceChain,
704
+ targetChain,
705
+ rawInventory: rawInventory.toString(),
706
+ rawInventoryEth: (Number(rawInventory) / 1e18).toFixed(6),
707
+ quotedGas: quote.gasCosts.toString(),
708
+ estimatedGas: estimatedGas.toString(),
709
+ estimatedGasEth: (Number(estimatedGas) / 1e18).toFixed(6),
710
+ maxGasThreshold: maxGasThreshold.toString(),
711
+ gasPercent: `${(Number(estimatedGas) * 100) / Number(rawInventory)}%`,
712
+ }, 'Bridge not viable - gas cost exceeds 10% of inventory');
713
+ return 0n;
714
+ }
715
+ // Max viable = inventory minus estimated gas
716
+ const maxViable = rawInventory - estimatedGas;
717
+ this.logger.info({
718
+ sourceChain,
719
+ targetChain,
720
+ rawInventory: rawInventory.toString(),
721
+ rawInventoryEth: (Number(rawInventory) / 1e18).toFixed(6),
722
+ quotedGas: quote.gasCosts.toString(),
723
+ estimatedGas: estimatedGas.toString(),
724
+ estimatedGasEth: (Number(estimatedGas) / 1e18).toFixed(6),
725
+ maxViable: maxViable.toString(),
726
+ maxViableEth: (Number(maxViable) / 1e18).toFixed(6),
727
+ }, 'Calculated max viable bridge amount');
728
+ return maxViable;
729
+ }
730
+ catch (error) {
731
+ this.logger.warn({
732
+ sourceChain,
733
+ targetChain,
734
+ error: error.message,
735
+ }, 'Failed to calculate max viable bridge amount, skipping chain');
736
+ return 0n;
737
+ }
738
+ }
739
+ /**
740
+ * Execute inventory movement from source chain to target chain via LiFi bridge.
741
+ *
742
+ * IMPORTANT: The amount parameter is now the MAX VIABLE amount (gas already subtracted
743
+ * by calculateMaxViableBridgeAmount). This method trusts that the amount is pre-validated.
744
+ *
745
+ * @param sourceChain - Chain to move inventory from
746
+ * @param targetChain - Chain to move inventory to (origin chain for rebalancing)
747
+ * @param amount - Pre-validated amount to bridge (gas already accounted for)
748
+ * @param intent - Rebalance intent for tracking
749
+ * @param externalBridgeType - External bridge type to use
750
+ * @returns Result with success status and optional txHash/error
751
+ */
752
+ async executeInventoryMovement(sourceChain, targetChain, amount, intent, externalBridgeType) {
753
+ const sourceToken = this.getTokenForChain(sourceChain);
754
+ if (!sourceToken) {
755
+ return {
756
+ success: false,
757
+ error: `No token found for source chain: ${sourceChain}`,
758
+ };
759
+ }
760
+ const targetToken = this.getTokenForChain(targetChain);
761
+ if (!targetToken) {
762
+ return {
763
+ success: false,
764
+ error: `No token found for target chain: ${targetChain}`,
765
+ };
766
+ }
767
+ // Get chain IDs for the external bridge (not domain IDs)
768
+ // Convert to number since getChainId can return string | number
769
+ const sourceChainId = Number(this.multiProvider.getChainId(sourceChain));
770
+ const targetChainId = Number(this.multiProvider.getChainId(targetChain));
771
+ // Convert HypNative token addresses to LiFi's native ETH representation
772
+ // For HypNative tokens, addressOrDenom is the warp route contract, not the native token
773
+ const fromTokenAddress = isNativeTokenStandard(sourceToken.standard)
774
+ ? this.getNativeTokenAddress(externalBridgeType)
775
+ : sourceToken.addressOrDenom;
776
+ const toTokenAddress = isNativeTokenStandard(targetToken.standard)
777
+ ? this.getNativeTokenAddress(externalBridgeType)
778
+ : targetToken.addressOrDenom;
779
+ this.logger.debug({
780
+ sourceTokenStandard: sourceToken.standard,
781
+ targetTokenStandard: targetToken.standard,
782
+ fromTokenAddress,
783
+ toTokenAddress,
784
+ }, 'Resolved token addresses for LiFi bridge');
785
+ // Calculate minViableTransfer for the target chain
786
+ // If bridging less than this, the received amount won't be enough to execute transferRemote
787
+ // So we over-bridge to ensure we can complete the intent in the next cycle
788
+ const costs = await calculateTransferCosts(targetChain, // FROM chain for transferRemote (the target of this bridge)
789
+ sourceChain, // TO chain for transferRemote (Hyperlane message destination)
790
+ amount, // availableInventory (not used for minViableTransfer calculation)
791
+ amount, // requestedAmount
792
+ this.multiProvider, this.warpCore.multiProvider, this.getTokenForChain.bind(this), this.config.inventorySigner, isNativeTokenStandard, this.logger);
793
+ const { minViableTransfer } = costs;
794
+ // If the requested amount is below minViableTransfer, adjust it up
795
+ // This ensures we bridge enough to actually complete the final transferRemote
796
+ const effectiveAmount = amount < minViableTransfer ? minViableTransfer : amount;
797
+ if (effectiveAmount !== amount) {
798
+ this.logger.info({
799
+ originalAmount: amount.toString(),
800
+ effectiveAmount: effectiveAmount.toString(),
801
+ minViableTransfer: minViableTransfer.toString(),
802
+ originalAmountEth: (Number(amount) / 1e18).toFixed(6),
803
+ effectiveAmountEth: (Number(effectiveAmount) / 1e18).toFixed(6),
804
+ minViableTransferEth: (Number(minViableTransfer) / 1e18).toFixed(6),
805
+ adjustedUp: true,
806
+ intentId: intent.id,
807
+ }, 'Over-bridging to minViableTransfer to ensure final transferRemote can complete');
808
+ }
809
+ try {
810
+ const externalBridge = this.getExternalBridge(externalBridgeType);
811
+ const quote = await externalBridge.quote({
812
+ fromChain: sourceChainId,
813
+ toChain: targetChainId,
814
+ fromToken: fromTokenAddress,
815
+ toToken: toTokenAddress,
816
+ fromAmount: effectiveAmount,
817
+ fromAddress: this.config.inventorySigner,
818
+ toAddress: this.config.inventorySigner,
819
+ });
820
+ const inputRequired = quote.fromAmount;
821
+ this.logger.info({
822
+ sourceChain,
823
+ targetChain,
824
+ sourceChainId,
825
+ targetChainId,
826
+ preValidatedAmount: amount.toString(),
827
+ preValidatedAmountEth: (Number(amount) / 1e18).toFixed(6),
828
+ effectiveAmount: effectiveAmount.toString(),
829
+ effectiveAmountEth: (Number(effectiveAmount) / 1e18).toFixed(6),
830
+ inputRequired: inputRequired.toString(),
831
+ expectedOutput: quote.toAmount.toString(),
832
+ expectedOutputEth: (Number(quote.toAmount) / 1e18).toFixed(6),
833
+ gasCosts: quote.gasCosts.toString(),
834
+ feeCosts: quote.feeCosts.toString(),
835
+ intentId: intent.id,
836
+ adjustedForMinViable: effectiveAmount > amount,
837
+ }, 'Executing inventory movement via LiFi with pre-validated amount');
838
+ this.logger.debug({
839
+ quoteId: quote.id,
840
+ tool: quote.tool,
841
+ fromAmount: quote.fromAmount.toString(),
842
+ toAmount: quote.toAmount.toString(),
843
+ toAmountMin: quote.toAmountMin.toString(),
844
+ executionDuration: quote.executionDuration,
845
+ gasCosts: quote.gasCosts.toString(),
846
+ feeCosts: quote.feeCosts.toString(),
847
+ }, 'Received LiFi quote for inventory movement');
848
+ const signingProvider = this.config.inventoryMultiProvider ?? this.multiProvider;
849
+ const signer = signingProvider.getSigner(sourceChain);
850
+ assert(signer instanceof Wallet, `External bridge execution requires a Wallet signer with private key access, got ${signer.constructor.name}`);
851
+ const result = await externalBridge.execute(quote, signer.privateKey);
852
+ this.logger.info({
853
+ sourceChain,
854
+ targetChain,
855
+ txHash: result.txHash,
856
+ intentId: intent.id,
857
+ }, 'Inventory movement transaction executed');
858
+ await this.actionTracker.createRebalanceAction({
859
+ intentId: intent.id,
860
+ origin: this.multiProvider.getDomainId(sourceChain),
861
+ destination: this.multiProvider.getDomainId(targetChain),
862
+ amount: inputRequired,
863
+ type: 'inventory_movement',
864
+ txHash: result.txHash,
865
+ externalBridgeId: externalBridgeType,
866
+ });
867
+ // Track consumed inventory on source chain for this cycle
868
+ const currentConsumed = this.consumedInventory.get(sourceChain) ?? 0n;
869
+ this.consumedInventory.set(sourceChain, currentConsumed + inputRequired);
870
+ this.logger.debug({
871
+ sourceChain,
872
+ amountConsumed: inputRequired.toString(),
873
+ totalConsumed: (currentConsumed + inputRequired).toString(),
874
+ }, 'Updated consumed inventory after LiFi bridge');
875
+ return { success: true, txHash: result.txHash };
876
+ }
877
+ catch (error) {
878
+ this.logger.error({
879
+ sourceChain,
880
+ targetChain,
881
+ amount: amount.toString(),
882
+ intentId: intent.id,
883
+ error: error.message,
884
+ }, 'Failed to execute inventory movement');
885
+ return {
886
+ success: false,
887
+ error: error.message,
888
+ };
889
+ }
890
+ }
891
+ }
892
+ //# sourceMappingURL=InventoryRebalancer.js.map