@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,1265 @@
1
+ import type { Logger } from 'pino';
2
+ import { Wallet } from 'ethers';
3
+
4
+ import {
5
+ type AnnotatedEV5Transaction,
6
+ type ChainName,
7
+ type EthJsonRpcBlockParameterTag,
8
+ HyperlaneCore,
9
+ type InterchainGasQuote,
10
+ type MultiProvider,
11
+ TOKEN_COLLATERALIZED_STANDARDS,
12
+ type WarpCore,
13
+ } from '@hyperlane-xyz/sdk';
14
+ import { assert } from '@hyperlane-xyz/utils';
15
+
16
+ import type { ExternalBridgeType } from '../config/types.js';
17
+ import type {
18
+ ExternalBridgeRegistry,
19
+ IExternalBridge,
20
+ } from '../interfaces/IExternalBridge.js';
21
+ import type {
22
+ IInventoryRebalancer,
23
+ InventoryExecutionResult,
24
+ RebalancerType,
25
+ } from '../interfaces/IRebalancer.js';
26
+ import type { InventoryRoute } from '../interfaces/IStrategy.js';
27
+ import type { IActionTracker } from '../tracking/IActionTracker.js';
28
+ import type {
29
+ PartialInventoryIntent,
30
+ RebalanceIntent,
31
+ } from '../tracking/types.js';
32
+ import {
33
+ MIN_VIABLE_COST_MULTIPLIER,
34
+ calculateTransferCosts,
35
+ } from '../utils/gasEstimation.js';
36
+ import { isNativeTokenStandard } from '../utils/tokenUtils.js';
37
+
38
+ /**
39
+ * Buffer percentage to add when bridging inventory.
40
+ * Bridges (amount * (100 + BRIDGE_BUFFER_PERCENT)) / 100 to account for slippage.
41
+ */
42
+ const BRIDGE_BUFFER_PERCENT = 5n;
43
+
44
+ /**
45
+ * Multiplier applied to LiFi's quoted gas costs.
46
+ * LiFi consistently underestimates gas, and gas prices can spike significantly
47
+ * between quote and execution. Using 20x provides headroom for volatility
48
+ * (historically LiFi underestimates by ~14x).
49
+ */
50
+ const GAS_COST_MULTIPLIER = 20n;
51
+
52
+ /**
53
+ * Maximum percentage of inventory that gas costs can consume for a bridge to be viable.
54
+ * If gas exceeds this threshold, the bridge is not economically worthwhile.
55
+ */
56
+ const MAX_GAS_PERCENT_THRESHOLD = 10n;
57
+
58
+ /**
59
+ * Configuration for the InventoryRebalancer.
60
+ */
61
+ export interface InventoryRebalancerConfig {
62
+ /** EOA address of the inventory signer */
63
+ inventorySigner: string;
64
+ /** Optional MultiProvider with inventory signer for signing transactions */
65
+ inventoryMultiProvider?: MultiProvider;
66
+ /** Chains configured for inventory-based rebalancing (for validation) */
67
+ inventoryChains: ChainName[];
68
+ }
69
+
70
+ /**
71
+ * Executes inventory-based rebalances for chains that don't support MovableCollateralRouter.
72
+ *
73
+ * IMPORTANT: transferRemote ADDS collateral to the ORIGIN chain (where it's called FROM).
74
+ * So for a strategy route "base (surplus) → arbitrum (deficit)", we must:
75
+ * 1. Ensure inventory is available on the DESTINATION (deficit) chain - arbitrum
76
+ * 2. Call transferRemote FROM arbitrum TO base
77
+ * 3. This ADDS collateral to arbitrum (filling deficit) and releases from base (has surplus)
78
+ *
79
+ * The flow is:
80
+ * 1. Check if inventory is available on the destination (deficit) chain
81
+ * 2. If available, execute transferRemote from destination to origin (swapped direction)
82
+ * 3. If not available, bridge inventory to destination via LiFi, then execute transferRemote
83
+ *
84
+ * Actions created:
85
+ * - `inventory_movement`: LiFi bridge to move inventory to deficit chain
86
+ * - `inventory_deposit`: transferRemote to deposit collateral on deficit chain
87
+ */
88
+ export class InventoryRebalancer implements IInventoryRebalancer {
89
+ public readonly rebalancerType: RebalancerType = 'inventory';
90
+ private readonly logger: Logger;
91
+ private readonly config: InventoryRebalancerConfig;
92
+ private readonly actionTracker: IActionTracker;
93
+ private readonly externalBridgeRegistry: Partial<ExternalBridgeRegistry>;
94
+ private readonly warpCore: WarpCore;
95
+ private readonly multiProvider: MultiProvider;
96
+
97
+ /**
98
+ * Internal balance storage for inventory tracking.
99
+ * Updated via setInventoryBalances() before each rebalance cycle.
100
+ */
101
+ private inventoryBalances: Map<ChainName, bigint> = new Map();
102
+
103
+ /**
104
+ * Tracks inventory consumed during the current execution cycle.
105
+ * Cleared at the start of each execute() call.
106
+ * Used to prevent over-execution when multiple routes withdraw from the same chain.
107
+ */
108
+ private consumedInventory: Map<ChainName, bigint> = new Map();
109
+
110
+ constructor(
111
+ config: InventoryRebalancerConfig,
112
+ actionTracker: IActionTracker,
113
+ externalBridgeRegistry: Partial<ExternalBridgeRegistry>,
114
+ warpCore: WarpCore,
115
+ multiProvider: MultiProvider,
116
+ logger: Logger,
117
+ ) {
118
+ this.config = config;
119
+ this.actionTracker = actionTracker;
120
+ this.externalBridgeRegistry = externalBridgeRegistry;
121
+ this.warpCore = warpCore;
122
+ this.multiProvider = multiProvider;
123
+ this.logger = logger;
124
+
125
+ // Validate that all tokens are collateral-backed
126
+ // Synthetic tokens cannot be used with inventory rebalancing because:
127
+ // - transferRemote on synthetics mints new tokens (doesn't transfer collateral)
128
+ // - There's no collateral to deposit/withdraw
129
+ this.validateCollateralBackedTokens();
130
+
131
+ this.logger.info(
132
+ { inventorySigner: config.inventorySigner },
133
+ 'InventoryRebalancer initialized',
134
+ );
135
+ }
136
+
137
+ /**
138
+ * Get bridge instance by type from registry.
139
+ * Throws if bridge type not found.
140
+ */
141
+ private getExternalBridge(type: ExternalBridgeType): IExternalBridge {
142
+ const externalBridge = this.externalBridgeRegistry[type];
143
+ if (!externalBridge) {
144
+ throw new Error(`Bridge type '${type}' not found in registry`);
145
+ }
146
+ return externalBridge;
147
+ }
148
+
149
+ private getNativeTokenAddress(bridgeType: ExternalBridgeType): string {
150
+ const bridge = this.getExternalBridge(bridgeType);
151
+ const addr = bridge.getNativeTokenAddress?.();
152
+ if (!addr) {
153
+ throw new Error(
154
+ `Bridge '${bridge.externalBridgeId}' does not support getNativeTokenAddress()`,
155
+ );
156
+ }
157
+ return addr;
158
+ }
159
+
160
+ /**
161
+ * Validate that tokens on inventory chains are collateral-backed.
162
+ * Only checks tokens for chains configured with inventory-based rebalancing.
163
+ * Throws an error if any synthetic tokens are found on inventory chains.
164
+ */
165
+ private validateCollateralBackedTokens(): void {
166
+ const inventoryChainSet = new Set(this.config.inventoryChains);
167
+
168
+ for (const token of this.warpCore.tokens) {
169
+ // Only validate tokens for chains configured for inventory rebalancing
170
+ if (!inventoryChainSet.has(token.chainName)) {
171
+ continue;
172
+ }
173
+
174
+ if (!TOKEN_COLLATERALIZED_STANDARDS.includes(token.standard)) {
175
+ throw new Error(
176
+ `InventoryRebalancer cannot be used with synthetic token on chain "${token.chainName}". ` +
177
+ `Token standard "${token.standard}" is not collateral-backed. ` +
178
+ `Only collateral-backed standards are supported: ${TOKEN_COLLATERALIZED_STANDARDS.join(', ')}`,
179
+ );
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Get the token for a specific chain from WarpCore.
186
+ */
187
+ private getTokenForChain(chainName: ChainName) {
188
+ return this.warpCore.tokens.find((t) => t.chainName === chainName);
189
+ }
190
+
191
+ /**
192
+ * Set inventory balances from external source.
193
+ * Called before each rebalance cycle to update internal state.
194
+ */
195
+ setInventoryBalances(balances: Record<ChainName, bigint>): void {
196
+ this.inventoryBalances = new Map(Object.entries(balances));
197
+ this.logger.debug(
198
+ {
199
+ chains: Array.from(this.inventoryBalances.keys()),
200
+ balances: Object.fromEntries(
201
+ Array.from(this.inventoryBalances.entries()).map(
202
+ ([chain, balance]) => [chain, balance.toString()],
203
+ ),
204
+ ),
205
+ },
206
+ 'Updated inventory balances',
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Get available inventory for a chain.
212
+ * Returns 0n for unknown chains.
213
+ */
214
+ private getAvailableInventory(chain: ChainName): bigint {
215
+ return this.inventoryBalances.get(chain) ?? 0n;
216
+ }
217
+
218
+ /**
219
+ * Get all inventory balances.
220
+ */
221
+ private getBalances(): Map<ChainName, bigint> {
222
+ return this.inventoryBalances;
223
+ }
224
+
225
+ /**
226
+ * Calculate total inventory across all chains, excluding specified chains.
227
+ */
228
+ private getTotalInventory(excludeChains: ChainName[]): bigint {
229
+ const excludeSet = new Set(excludeChains);
230
+ let total = 0n;
231
+ for (const [chain, balance] of this.inventoryBalances) {
232
+ if (!excludeSet.has(chain)) {
233
+ total += balance;
234
+ }
235
+ }
236
+ return total;
237
+ }
238
+
239
+ /**
240
+ * Get the effective available inventory for a chain, accounting for
241
+ * inventory already consumed during this execution cycle.
242
+ *
243
+ * This prevents over-execution when multiple routes withdraw from the same chain.
244
+ *
245
+ * @param chain - The chain to check inventory for
246
+ * @returns Effective available inventory (cached - consumed)
247
+ */
248
+ private getEffectiveAvailableInventory(chain: ChainName): bigint {
249
+ const cached = this.getAvailableInventory(chain);
250
+ const consumed = this.consumedInventory.get(chain) ?? 0n;
251
+ const effective = cached > consumed ? cached - consumed : 0n;
252
+
253
+ if (consumed > 0n) {
254
+ this.logger.debug(
255
+ {
256
+ chain,
257
+ cachedInventory: cached.toString(),
258
+ consumedThisCycle: consumed.toString(),
259
+ effectiveInventory: effective.toString(),
260
+ },
261
+ 'Calculated effective inventory after prior executions',
262
+ );
263
+ }
264
+
265
+ return effective;
266
+ }
267
+
268
+ /**
269
+ * Execute inventory-based rebalances for the given routes.
270
+ *
271
+ * Single-intent architecture:
272
+ * 1. Check for existing in_progress intent
273
+ * 2. If exists, continue existing intent (ignores new routes)
274
+ * 3. If not, take only the FIRST route and create a single intent
275
+ */
276
+ async rebalance(
277
+ routes: InventoryRoute[],
278
+ ): Promise<InventoryExecutionResult[]> {
279
+ this.consumedInventory.clear();
280
+
281
+ // 1. Check for existing in_progress intent
282
+ const activeIntent = await this.getActiveInventoryIntent();
283
+
284
+ if (activeIntent) {
285
+ if (activeIntent.hasInflightDeposit) {
286
+ this.logger.info(
287
+ {
288
+ intentId: activeIntent.intent.id,
289
+ remaining: activeIntent.remaining.toString(),
290
+ },
291
+ 'Active intent has in-flight deposit, waiting for delivery before continuing',
292
+ );
293
+ return [];
294
+ }
295
+ // Continue existing intent, ignore new routes
296
+ this.logger.info(
297
+ {
298
+ intentId: activeIntent.intent.id,
299
+ remaining: activeIntent.remaining.toString(),
300
+ newRoutesIgnored: routes.length,
301
+ },
302
+ 'Continuing existing intent, ignoring new routes',
303
+ );
304
+ return this.continueIntent(activeIntent);
305
+ }
306
+
307
+ // 2. No existing intent - take first route only
308
+ if (routes.length === 0) return [];
309
+
310
+ const route = routes[0];
311
+ if (routes.length > 1) {
312
+ this.logger.info(
313
+ {
314
+ selectedRoute: `${route.origin} → ${route.destination}`,
315
+ discardedCount: routes.length - 1,
316
+ },
317
+ 'Taking first route only, discarding others',
318
+ );
319
+ }
320
+
321
+ // 3. Create intent and execute
322
+ const intent = await this.actionTracker.createRebalanceIntent({
323
+ origin: this.multiProvider.getDomainId(route.origin),
324
+ destination: this.multiProvider.getDomainId(route.destination),
325
+ amount: route.amount,
326
+ executionMethod: 'inventory',
327
+ externalBridge: route.externalBridge,
328
+ });
329
+
330
+ this.logger.debug(
331
+ {
332
+ intentId: intent.id,
333
+ origin: route.origin,
334
+ destination: route.destination,
335
+ amount: route.amount.toString(),
336
+ },
337
+ 'Created new inventory rebalance intent',
338
+ );
339
+
340
+ try {
341
+ const result = await this.executeRoute(route, intent);
342
+
343
+ // Update consumed inventory on success
344
+ if (result.success && result.amountSent) {
345
+ const current = this.consumedInventory.get(route.destination) ?? 0n;
346
+ this.consumedInventory.set(
347
+ route.destination,
348
+ current + result.amountSent,
349
+ );
350
+ }
351
+
352
+ return [result];
353
+ } catch (error) {
354
+ this.logger.error(
355
+ {
356
+ route,
357
+ intentId: intent.id,
358
+ error: (error as Error).message,
359
+ },
360
+ 'Failed to execute inventory route',
361
+ );
362
+
363
+ return [
364
+ {
365
+ route,
366
+ success: false,
367
+ error: (error as Error).message,
368
+ },
369
+ ];
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Get the single active inventory intent (if any).
375
+ * Returns null if no in_progress inventory intent exists.
376
+ */
377
+ private async getActiveInventoryIntent(): Promise<PartialInventoryIntent | null> {
378
+ const partialIntents =
379
+ await this.actionTracker.getPartiallyFulfilledInventoryIntents();
380
+ return partialIntents.length > 0 ? partialIntents[0] : null;
381
+ }
382
+
383
+ /**
384
+ * Continue execution of an existing partial intent.
385
+ * Uses the pre-computed remaining amount from PartialInventoryIntent.
386
+ */
387
+ private async continueIntent(
388
+ partial: PartialInventoryIntent,
389
+ ): Promise<InventoryExecutionResult[]> {
390
+ const { intent, remaining } = partial;
391
+
392
+ const route: InventoryRoute = {
393
+ origin: this.multiProvider.getChainName(intent.origin),
394
+ destination: this.multiProvider.getChainName(intent.destination),
395
+ amount: remaining,
396
+ executionType: 'inventory',
397
+ externalBridge: intent.externalBridge!,
398
+ };
399
+
400
+ this.logger.info(
401
+ {
402
+ intentId: intent.id,
403
+ origin: route.origin,
404
+ destination: route.destination,
405
+ remaining: remaining.toString(),
406
+ completed: partial.completedAmount.toString(),
407
+ total: intent.amount.toString(),
408
+ },
409
+ 'Continuing partial inventory intent',
410
+ );
411
+
412
+ // Warn if intent never started - indicates previous execution attempt failed
413
+ // without creating any actions (e.g., all bridges failed viability check)
414
+ if (intent.status === 'not_started') {
415
+ this.logger.warn(
416
+ {
417
+ intentId: intent.id,
418
+ origin: route.origin,
419
+ destination: route.destination,
420
+ },
421
+ 'Retrying intent that never started - previous execution attempt failed without creating any actions',
422
+ );
423
+ }
424
+
425
+ try {
426
+ const result = await this.executeRoute(route, intent);
427
+
428
+ // Update consumed inventory on success
429
+ if (result.success && result.amountSent) {
430
+ const current = this.consumedInventory.get(route.destination) ?? 0n;
431
+ this.consumedInventory.set(
432
+ route.destination,
433
+ current + result.amountSent,
434
+ );
435
+ }
436
+
437
+ return [result];
438
+ } catch (error) {
439
+ this.logger.error(
440
+ {
441
+ route,
442
+ intentId: intent.id,
443
+ error: (error as Error).message,
444
+ },
445
+ 'Failed to continue partial inventory intent',
446
+ );
447
+
448
+ return [
449
+ {
450
+ route,
451
+ success: false,
452
+ error: (error as Error).message,
453
+ },
454
+ ];
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Execute a single inventory route.
460
+ *
461
+ * Strategy provides: origin (surplus) → destination (deficit)
462
+ * "Move collateral FROM origin TO destination"
463
+ *
464
+ * IMPORTANT: transferRemote ADDS collateral to the chain it's called FROM.
465
+ * So to fill the deficit on destination, we must:
466
+ * - Call transferRemote FROM destination TO origin (SWAPPED direction)
467
+ * - This ADDS to destination (deficit filled!) and RELEASES from origin (has surplus)
468
+ *
469
+ * Execution flow:
470
+ * 1. Check inventory on DESTINATION (deficit chain) - need funds there to call transferRemote
471
+ * 2. If low, LiFi bridge TO destination
472
+ * 3. Call transferRemote FROM destination TO origin (swapped)
473
+ */
474
+ private async executeRoute(
475
+ route: InventoryRoute,
476
+ intent: RebalanceIntent,
477
+ ): Promise<InventoryExecutionResult> {
478
+ const { origin, destination, amount } = route;
479
+
480
+ this.logger.info(
481
+ {
482
+ strategyRoute: `${origin} (surplus) → ${destination} (deficit)`,
483
+ executionRoute: `transferRemote FROM ${destination} TO ${origin}`,
484
+ amount: amount.toString(),
485
+ intentId: intent.id,
486
+ },
487
+ 'Executing inventory route',
488
+ );
489
+
490
+ // Check available inventory on the DESTINATION (deficit) chain
491
+ // We need inventory here because transferRemote is called FROM this chain
492
+ const availableInventory = this.getEffectiveAvailableInventory(destination);
493
+
494
+ this.logger.info(
495
+ {
496
+ checkingChain: destination,
497
+ availableInventory: availableInventory.toString(),
498
+ availableInventoryEth: (Number(availableInventory) / 1e18).toFixed(6),
499
+ requiredAmount: amount.toString(),
500
+ requiredAmountEth: (Number(amount) / 1e18).toFixed(6),
501
+ },
502
+ 'Checking effective inventory on destination (deficit) chain',
503
+ );
504
+
505
+ // Calculate transfer costs including max transferable and min viable amounts
506
+ // transferRemote is called FROM destination TO origin (swapped direction)
507
+ const costs = await calculateTransferCosts(
508
+ destination, // FROM chain (where transferRemote is called)
509
+ origin, // TO chain (where Hyperlane message goes)
510
+ availableInventory,
511
+ amount,
512
+ this.multiProvider,
513
+ this.warpCore.multiProvider,
514
+ this.getTokenForChain.bind(this),
515
+ this.config.inventorySigner,
516
+ isNativeTokenStandard,
517
+ this.logger,
518
+ );
519
+ const { maxTransferable, minViableTransfer } = costs;
520
+
521
+ // Calculate total inventory across all chains
522
+ // Note: consumedInventory tracking is handled separately within this cycle
523
+ const totalInventory = this.getTotalInventory([]);
524
+
525
+ this.logger.info(
526
+ {
527
+ fromChain: destination,
528
+ toChain: origin,
529
+ availableInventoryEth: (Number(availableInventory) / 1e18).toFixed(6),
530
+ requestedAmountEth: (Number(amount) / 1e18).toFixed(6),
531
+ maxTransferableEth: (Number(maxTransferable) / 1e18).toFixed(6),
532
+ minViableTransferEth: (Number(minViableTransfer) / 1e18).toFixed(6),
533
+ totalInventoryEth: (Number(totalInventory) / 1e18).toFixed(6),
534
+ canFullyFulfill: maxTransferable >= amount,
535
+ canPartialFulfill: maxTransferable >= minViableTransfer,
536
+ },
537
+ 'Calculated max transferable amount with cost-based threshold',
538
+ );
539
+
540
+ // Early exit: If remaining amount is below minViableTransfer, complete the intent
541
+ // This prevents infinite loops when the remaining amount is too small to economically bridge
542
+ if (amount < minViableTransfer) {
543
+ this.logger.info(
544
+ {
545
+ intentId: intent.id,
546
+ amount: amount.toString(),
547
+ minViableTransfer: minViableTransfer.toString(),
548
+ },
549
+ 'Remaining amount below minViableTransfer, completing intent with acceptable loss',
550
+ );
551
+
552
+ await this.actionTracker.completeRebalanceIntent(intent.id);
553
+
554
+ return {
555
+ route,
556
+ success: true,
557
+ reason: 'completed_with_acceptable_loss',
558
+ };
559
+ }
560
+
561
+ // Swap the route for executeTransferRemote: destination → origin
562
+ // This ensures transferRemote is called FROM destination, ADDING collateral there
563
+ const swappedRoute: InventoryRoute = {
564
+ ...route,
565
+ origin: destination, // transferRemote called FROM here
566
+ destination: origin, // Hyperlane message goes TO here
567
+ };
568
+
569
+ if (maxTransferable >= amount) {
570
+ // Sufficient inventory on destination - execute transferRemote directly
571
+ const result = await this.executeTransferRemote(
572
+ swappedRoute,
573
+ intent,
574
+ costs.gasQuote!,
575
+ );
576
+ // Return original strategy route in result (not the swapped execution route)
577
+ return { ...result, route };
578
+ } else if (maxTransferable > 0n && maxTransferable >= minViableTransfer) {
579
+ // Partial transfer: Transfer available inventory when economically viable
580
+ const partialSwappedRoute: InventoryRoute = {
581
+ ...swappedRoute,
582
+ amount: maxTransferable,
583
+ };
584
+ const result = await this.executeTransferRemote(
585
+ partialSwappedRoute,
586
+ intent,
587
+ costs.gasQuote!,
588
+ );
589
+
590
+ this.logger.info(
591
+ {
592
+ intentId: intent.id,
593
+ partialAmount: maxTransferable.toString(),
594
+ requestedAmount: amount.toString(),
595
+ remainingAmount: (amount - maxTransferable).toString(),
596
+ },
597
+ 'Executed partial inventory deposit, remaining will be handled in future cycles',
598
+ );
599
+
600
+ // Return original strategy route in result (not the swapped execution route)
601
+ return { ...result, route };
602
+ } else {
603
+ // Inventory below cost-based threshold - trigger ExternalBridge movement TO destination chain
604
+ this.logger.info(
605
+ {
606
+ targetChain: destination,
607
+ maxTransferable: maxTransferable.toString(),
608
+ minViableTransfer: minViableTransfer.toString(),
609
+ costMultiplier: MIN_VIABLE_COST_MULTIPLIER.toString(),
610
+ intentId: intent.id,
611
+ },
612
+ 'Inventory below cost-based threshold on destination, triggering LiFi movement',
613
+ );
614
+
615
+ // Get all available source chains with raw inventory
616
+ const allSources = this.selectAllSourceChains(destination);
617
+
618
+ if (allSources.length === 0) {
619
+ this.logger.warn(
620
+ {
621
+ origin,
622
+ destination,
623
+ amount: amount.toString(),
624
+ intentId: intent.id,
625
+ },
626
+ 'No inventory available on any monitored chain',
627
+ );
628
+
629
+ return {
630
+ route,
631
+ success: false,
632
+ error: 'No inventory available on any monitored chain',
633
+ };
634
+ }
635
+
636
+ // NEW: Calculate max viable amount for each source chain
637
+ // This uses the quote API to determine gas costs upfront
638
+ const viableSources: Array<{ chain: ChainName; maxViable: bigint }> = [];
639
+
640
+ for (const source of allSources) {
641
+ const maxViable = await this.calculateMaxViableBridgeAmount(
642
+ source.chain,
643
+ destination,
644
+ source.availableAmount,
645
+ route.externalBridge,
646
+ );
647
+
648
+ if (maxViable > 0n) {
649
+ viableSources.push({ chain: source.chain, maxViable });
650
+ }
651
+ }
652
+
653
+ // Sort by max viable descending (bridge from largest sources first)
654
+ viableSources.sort((a, b) => (a.maxViable > b.maxViable ? -1 : 1));
655
+
656
+ if (viableSources.length === 0) {
657
+ this.logger.warn(
658
+ {
659
+ targetChain: destination,
660
+ sourcesChecked: allSources.length,
661
+ intentId: intent.id,
662
+ },
663
+ 'No viable bridge sources - all chains have insufficient inventory or high gas costs',
664
+ );
665
+
666
+ return {
667
+ route,
668
+ success: false,
669
+ error: 'No viable bridge sources available',
670
+ };
671
+ }
672
+
673
+ // Create bridge plans using VIABLE amounts (gas already accounted for)
674
+ const targetWithBuffer =
675
+ ((amount + costs.totalCost) * (100n + BRIDGE_BUFFER_PERCENT)) / 100n;
676
+ const bridgePlans: Array<{ chain: ChainName; amount: bigint }> = [];
677
+ let totalPlanned = 0n;
678
+
679
+ for (const source of viableSources) {
680
+ if (totalPlanned >= targetWithBuffer) break;
681
+
682
+ const remaining = targetWithBuffer - totalPlanned;
683
+ const amountFromSource =
684
+ source.maxViable >= remaining ? remaining : source.maxViable; // Already gas-adjusted!
685
+
686
+ bridgePlans.push({
687
+ chain: source.chain,
688
+ amount: amountFromSource,
689
+ });
690
+ totalPlanned += amountFromSource;
691
+ }
692
+
693
+ this.logger.info(
694
+ {
695
+ targetChain: destination,
696
+ viableSources: viableSources.map((s) => ({
697
+ chain: s.chain,
698
+ maxViable: s.maxViable.toString(),
699
+ maxViableEth: (Number(s.maxViable) / 1e18).toFixed(6),
700
+ })),
701
+ bridgePlans: bridgePlans.map((p) => ({
702
+ chain: p.chain,
703
+ amount: p.amount.toString(),
704
+ amountEth: (Number(p.amount) / 1e18).toFixed(6),
705
+ })),
706
+ totalPlanned: totalPlanned.toString(),
707
+ targetWithBuffer: targetWithBuffer.toString(),
708
+ intentId: intent.id,
709
+ },
710
+ 'Created bridge plans using gas-adjusted viable amounts',
711
+ );
712
+
713
+ // Execute all bridges in parallel
714
+ const bridgeResults = await Promise.allSettled(
715
+ bridgePlans.map((plan) =>
716
+ this.executeInventoryMovement(
717
+ plan.chain,
718
+ destination,
719
+ plan.amount,
720
+ intent,
721
+ route.externalBridge,
722
+ ),
723
+ ),
724
+ );
725
+
726
+ // Process results
727
+ let successCount = 0;
728
+ let totalBridged = 0n;
729
+ const failedErrors: string[] = [];
730
+
731
+ for (let i = 0; i < bridgeResults.length; i++) {
732
+ const result = bridgeResults[i];
733
+ const plan = bridgePlans[i];
734
+
735
+ if (result.status === 'fulfilled' && result.value.success) {
736
+ successCount++;
737
+ totalBridged += plan.amount;
738
+ this.logger.info(
739
+ {
740
+ sourceChain: plan.chain,
741
+ amount: plan.amount.toString(),
742
+ txHash: result.value.txHash,
743
+ },
744
+ 'Inventory movement succeeded',
745
+ );
746
+ } else {
747
+ const error =
748
+ result.status === 'rejected'
749
+ ? result.reason?.message
750
+ : result.value.error;
751
+ if (error) {
752
+ failedErrors.push(`${plan.chain}: ${error}`);
753
+ }
754
+ this.logger.warn(
755
+ {
756
+ sourceChain: plan.chain,
757
+ amount: plan.amount.toString(),
758
+ error,
759
+ },
760
+ 'Inventory movement failed',
761
+ );
762
+ }
763
+ }
764
+
765
+ if (successCount === 0) {
766
+ const errorDetails =
767
+ failedErrors.length > 0 ? ` (${failedErrors.join('; ')})` : '';
768
+ return {
769
+ route,
770
+ success: false,
771
+ error: `All inventory movements failed${errorDetails}`,
772
+ };
773
+ }
774
+
775
+ this.logger.info(
776
+ {
777
+ targetChain: destination,
778
+ successCount,
779
+ totalBridged: totalBridged.toString(),
780
+ targetAmount: amount.toString(),
781
+ intentId: intent.id,
782
+ },
783
+ 'Parallel inventory movements completed, transferRemote will execute after bridges complete',
784
+ );
785
+
786
+ return { route, success: true };
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Execute a transferRemote to deposit collateral.
792
+ *
793
+ * IMPORTANT: The route passed here has SWAPPED direction from the strategy route.
794
+ * - route.origin = the deficit chain (where transferRemote is called FROM)
795
+ * - route.destination = the surplus chain (where Hyperlane message goes TO)
796
+ *
797
+ * transferRemote mechanics:
798
+ * - Calls _transferFromSender() which ADDS collateral to route.origin
799
+ * - Sends Hyperlane message to route.destination to RELEASE collateral
800
+ *
801
+ * @param route - The transfer route (swapped direction)
802
+ * @param intent - The rebalance intent being executed
803
+ * @param gasQuote - Pre-calculated gas quote from calculateTransferCosts
804
+ */
805
+ private async executeTransferRemote(
806
+ route: InventoryRoute,
807
+ intent: RebalanceIntent,
808
+ gasQuote: InterchainGasQuote,
809
+ ): Promise<InventoryExecutionResult> {
810
+ const { origin, destination, amount } = route;
811
+
812
+ const originToken = this.getTokenForChain(origin);
813
+ if (!originToken) {
814
+ throw new Error(`No token found for origin chain: ${origin}`);
815
+ }
816
+
817
+ const destinationDomain = this.multiProvider.getDomainId(destination);
818
+
819
+ // Get the hyperlane adapter for the token
820
+ const adapter = originToken.getHypAdapter(this.warpCore.multiProvider);
821
+
822
+ this.logger.debug(
823
+ {
824
+ origin,
825
+ destination,
826
+ amount: amount.toString(),
827
+ gasQuote: {
828
+ igpQuote: gasQuote.igpQuote.amount.toString(),
829
+ tokenFeeQuote: gasQuote.tokenFeeQuote?.amount?.toString() ?? 'none',
830
+ },
831
+ },
832
+ 'Using pre-calculated gas quote for transferRemote',
833
+ );
834
+
835
+ // Populate the transferRemote transaction
836
+ const populatedTx = await adapter.populateTransferRemoteTx({
837
+ destination: destinationDomain,
838
+ recipient: this.config.inventorySigner,
839
+ weiAmountOrId: amount,
840
+ interchainGas: gasQuote,
841
+ });
842
+
843
+ // Send the transaction using inventory MultiProvider if available
844
+ this.logger.info(
845
+ {
846
+ origin,
847
+ destination,
848
+ amount: amount.toString(),
849
+ intentId: intent.id,
850
+ },
851
+ 'Sending transferRemote transaction',
852
+ );
853
+
854
+ // Use inventoryMultiProvider if available, otherwise fall back to multiProvider
855
+ const signingProvider =
856
+ this.config.inventoryMultiProvider ?? this.multiProvider;
857
+
858
+ // Get reorgPeriod for confirmation waiting
859
+ const reorgPeriod =
860
+ this.multiProvider.getChainMetadata(origin).blocks?.reorgPeriod ?? 32;
861
+
862
+ // Wait for reorgPeriod confirmations via SDK to ensure Monitor sees balance changes
863
+ const receipt = await signingProvider.sendTransaction(
864
+ origin,
865
+ populatedTx as AnnotatedEV5Transaction,
866
+ {
867
+ waitConfirmations: reorgPeriod as number | EthJsonRpcBlockParameterTag,
868
+ },
869
+ );
870
+
871
+ // Extract messageId from the transaction receipt logs
872
+ const dispatchedMessages = HyperlaneCore.getDispatchedMessages(receipt);
873
+ const messageId = dispatchedMessages[0]?.id;
874
+
875
+ if (!messageId) {
876
+ this.logger.warn(
877
+ {
878
+ origin,
879
+ destination,
880
+ txHash: receipt.transactionHash,
881
+ intentId: intent.id,
882
+ },
883
+ 'TransferRemote transaction sent but no messageId found in logs',
884
+ );
885
+ }
886
+
887
+ this.logger.info(
888
+ {
889
+ origin,
890
+ destination,
891
+ txHash: receipt.transactionHash,
892
+ messageId,
893
+ intentId: intent.id,
894
+ },
895
+ 'TransferRemote transaction confirmed',
896
+ );
897
+
898
+ // Create the inventory_deposit action with messageId for tracking
899
+ await this.actionTracker.createRebalanceAction({
900
+ intentId: intent.id,
901
+ origin: this.multiProvider.getDomainId(origin),
902
+ destination: destinationDomain,
903
+ amount,
904
+ type: 'inventory_deposit',
905
+ txHash: receipt.transactionHash,
906
+ messageId,
907
+ });
908
+
909
+ return {
910
+ route,
911
+ success: true,
912
+ amountSent: amount,
913
+ };
914
+ }
915
+
916
+ /**
917
+ * Select all source chains with available inventory for bridging.
918
+ * Returns sources sorted by available amount (highest first).
919
+ */
920
+ private selectAllSourceChains(
921
+ targetChain: ChainName,
922
+ ): Array<{ chain: ChainName; availableAmount: bigint }> {
923
+ const balances = this.getBalances();
924
+ const sources: Array<{ chain: ChainName; availableAmount: bigint }> = [];
925
+
926
+ for (const [chainName, balance] of balances) {
927
+ if (chainName === targetChain) continue;
928
+
929
+ const consumed = this.consumedInventory.get(chainName) ?? 0n;
930
+ const effectiveAvailable = balance > consumed ? balance - consumed : 0n;
931
+
932
+ if (effectiveAvailable > 0n) {
933
+ sources.push({
934
+ chain: chainName,
935
+ availableAmount: effectiveAvailable,
936
+ });
937
+ }
938
+ }
939
+
940
+ // Sort by available amount descending (bridge from largest sources first)
941
+ return sources.sort((a, b) =>
942
+ a.availableAmount > b.availableAmount ? -1 : 1,
943
+ );
944
+ }
945
+
946
+ /**
947
+ * Calculate the maximum amount that can be bridged from a source chain.
948
+ * Uses LiFi quote to determine gas costs, applies 20x multiplier buffer.
949
+ * Returns 0 if gas exceeds 10% of inventory (not economically viable).
950
+ *
951
+ * This is the key method for the gas-aware planning approach:
952
+ * - Gets a quote for the full raw inventory to determine actual gas costs
953
+ * - Applies conservative 20x buffer (LiFi underestimates by ~14x historically)
954
+ * - Returns 0 if gas > 10% of inventory (not worth bridging)
955
+ * - Returns inventory - estimatedGas if viable
956
+ *
957
+ * @param sourceChain - Chain to bridge from
958
+ * @param targetChain - Chain to bridge to
959
+ * @param rawInventory - Raw available inventory on source chain
960
+ * @param externalBridgeType - External bridge type to use
961
+ * @returns Maximum viable bridge amount (0 if not viable)
962
+ */
963
+ private async calculateMaxViableBridgeAmount(
964
+ sourceChain: ChainName,
965
+ targetChain: ChainName,
966
+ rawInventory: bigint,
967
+ externalBridgeType: ExternalBridgeType,
968
+ ): Promise<bigint> {
969
+ const sourceToken = this.getTokenForChain(sourceChain);
970
+ const targetToken = this.getTokenForChain(targetChain);
971
+
972
+ if (!sourceToken || !targetToken) return 0n;
973
+
974
+ // Only applies to native tokens (need gas from same balance)
975
+ if (!isNativeTokenStandard(sourceToken.standard)) {
976
+ return rawInventory; // ERC20s don't compete with gas
977
+ }
978
+
979
+ // Convert HypNative token addresses to LiFi's native ETH representation
980
+ const fromTokenAddress = this.getNativeTokenAddress(externalBridgeType);
981
+ const toTokenAddress = isNativeTokenStandard(targetToken.standard)
982
+ ? this.getNativeTokenAddress(externalBridgeType)
983
+ : targetToken.addressOrDenom;
984
+
985
+ const sourceChainId = Number(this.multiProvider.getChainId(sourceChain));
986
+ const targetChainId = Number(this.multiProvider.getChainId(targetChain));
987
+
988
+ try {
989
+ const exteralBridge = this.getExternalBridge(externalBridgeType);
990
+ const quote = await exteralBridge.quote({
991
+ fromChain: sourceChainId,
992
+ toChain: targetChainId,
993
+ fromToken: fromTokenAddress,
994
+ toToken: toTokenAddress,
995
+ fromAmount: rawInventory,
996
+ fromAddress: this.config.inventorySigner,
997
+ toAddress: this.config.inventorySigner,
998
+ });
999
+
1000
+ // Apply 20x multiplier on quoted gas (LiFi underestimates by ~14x)
1001
+ const estimatedGas = quote.gasCosts * GAS_COST_MULTIPLIER;
1002
+
1003
+ // Viability check: gas should not exceed 10% of inventory
1004
+ const maxGasThreshold = rawInventory / MAX_GAS_PERCENT_THRESHOLD;
1005
+ if (estimatedGas > maxGasThreshold) {
1006
+ this.logger.info(
1007
+ {
1008
+ sourceChain,
1009
+ targetChain,
1010
+ rawInventory: rawInventory.toString(),
1011
+ rawInventoryEth: (Number(rawInventory) / 1e18).toFixed(6),
1012
+ quotedGas: quote.gasCosts.toString(),
1013
+ estimatedGas: estimatedGas.toString(),
1014
+ estimatedGasEth: (Number(estimatedGas) / 1e18).toFixed(6),
1015
+ maxGasThreshold: maxGasThreshold.toString(),
1016
+ gasPercent: `${(Number(estimatedGas) * 100) / Number(rawInventory)}%`,
1017
+ },
1018
+ 'Bridge not viable - gas cost exceeds 10% of inventory',
1019
+ );
1020
+ return 0n;
1021
+ }
1022
+
1023
+ // Max viable = inventory minus estimated gas
1024
+ const maxViable = rawInventory - estimatedGas;
1025
+
1026
+ this.logger.info(
1027
+ {
1028
+ sourceChain,
1029
+ targetChain,
1030
+ rawInventory: rawInventory.toString(),
1031
+ rawInventoryEth: (Number(rawInventory) / 1e18).toFixed(6),
1032
+ quotedGas: quote.gasCosts.toString(),
1033
+ estimatedGas: estimatedGas.toString(),
1034
+ estimatedGasEth: (Number(estimatedGas) / 1e18).toFixed(6),
1035
+ maxViable: maxViable.toString(),
1036
+ maxViableEth: (Number(maxViable) / 1e18).toFixed(6),
1037
+ },
1038
+ 'Calculated max viable bridge amount',
1039
+ );
1040
+
1041
+ return maxViable;
1042
+ } catch (error) {
1043
+ this.logger.warn(
1044
+ {
1045
+ sourceChain,
1046
+ targetChain,
1047
+ error: (error as Error).message,
1048
+ },
1049
+ 'Failed to calculate max viable bridge amount, skipping chain',
1050
+ );
1051
+ return 0n;
1052
+ }
1053
+ }
1054
+
1055
+ /**
1056
+ * Execute inventory movement from source chain to target chain via LiFi bridge.
1057
+ *
1058
+ * IMPORTANT: The amount parameter is now the MAX VIABLE amount (gas already subtracted
1059
+ * by calculateMaxViableBridgeAmount). This method trusts that the amount is pre-validated.
1060
+ *
1061
+ * @param sourceChain - Chain to move inventory from
1062
+ * @param targetChain - Chain to move inventory to (origin chain for rebalancing)
1063
+ * @param amount - Pre-validated amount to bridge (gas already accounted for)
1064
+ * @param intent - Rebalance intent for tracking
1065
+ * @param externalBridgeType - External bridge type to use
1066
+ * @returns Result with success status and optional txHash/error
1067
+ */
1068
+ private async executeInventoryMovement(
1069
+ sourceChain: ChainName,
1070
+ targetChain: ChainName,
1071
+ amount: bigint,
1072
+ intent: RebalanceIntent,
1073
+ externalBridgeType: ExternalBridgeType,
1074
+ ): Promise<{ success: boolean; txHash?: string; error?: string }> {
1075
+ const sourceToken = this.getTokenForChain(sourceChain);
1076
+ if (!sourceToken) {
1077
+ return {
1078
+ success: false,
1079
+ error: `No token found for source chain: ${sourceChain}`,
1080
+ };
1081
+ }
1082
+
1083
+ const targetToken = this.getTokenForChain(targetChain);
1084
+ if (!targetToken) {
1085
+ return {
1086
+ success: false,
1087
+ error: `No token found for target chain: ${targetChain}`,
1088
+ };
1089
+ }
1090
+
1091
+ // Get chain IDs for the external bridge (not domain IDs)
1092
+ // Convert to number since getChainId can return string | number
1093
+ const sourceChainId = Number(this.multiProvider.getChainId(sourceChain));
1094
+ const targetChainId = Number(this.multiProvider.getChainId(targetChain));
1095
+
1096
+ // Convert HypNative token addresses to LiFi's native ETH representation
1097
+ // For HypNative tokens, addressOrDenom is the warp route contract, not the native token
1098
+ const fromTokenAddress = isNativeTokenStandard(sourceToken.standard)
1099
+ ? this.getNativeTokenAddress(externalBridgeType)
1100
+ : sourceToken.addressOrDenom;
1101
+
1102
+ const toTokenAddress = isNativeTokenStandard(targetToken.standard)
1103
+ ? this.getNativeTokenAddress(externalBridgeType)
1104
+ : targetToken.addressOrDenom;
1105
+
1106
+ this.logger.debug(
1107
+ {
1108
+ sourceTokenStandard: sourceToken.standard,
1109
+ targetTokenStandard: targetToken.standard,
1110
+ fromTokenAddress,
1111
+ toTokenAddress,
1112
+ },
1113
+ 'Resolved token addresses for LiFi bridge',
1114
+ );
1115
+
1116
+ // Calculate minViableTransfer for the target chain
1117
+ // If bridging less than this, the received amount won't be enough to execute transferRemote
1118
+ // So we over-bridge to ensure we can complete the intent in the next cycle
1119
+ const costs = await calculateTransferCosts(
1120
+ targetChain, // FROM chain for transferRemote (the target of this bridge)
1121
+ sourceChain, // TO chain for transferRemote (Hyperlane message destination)
1122
+ amount, // availableInventory (not used for minViableTransfer calculation)
1123
+ amount, // requestedAmount
1124
+ this.multiProvider,
1125
+ this.warpCore.multiProvider,
1126
+ this.getTokenForChain.bind(this),
1127
+ this.config.inventorySigner,
1128
+ isNativeTokenStandard,
1129
+ this.logger,
1130
+ );
1131
+ const { minViableTransfer } = costs;
1132
+
1133
+ // If the requested amount is below minViableTransfer, adjust it up
1134
+ // This ensures we bridge enough to actually complete the final transferRemote
1135
+ const effectiveAmount =
1136
+ amount < minViableTransfer ? minViableTransfer : amount;
1137
+
1138
+ if (effectiveAmount !== amount) {
1139
+ this.logger.info(
1140
+ {
1141
+ originalAmount: amount.toString(),
1142
+ effectiveAmount: effectiveAmount.toString(),
1143
+ minViableTransfer: minViableTransfer.toString(),
1144
+ originalAmountEth: (Number(amount) / 1e18).toFixed(6),
1145
+ effectiveAmountEth: (Number(effectiveAmount) / 1e18).toFixed(6),
1146
+ minViableTransferEth: (Number(minViableTransfer) / 1e18).toFixed(6),
1147
+ adjustedUp: true,
1148
+ intentId: intent.id,
1149
+ },
1150
+ 'Over-bridging to minViableTransfer to ensure final transferRemote can complete',
1151
+ );
1152
+ }
1153
+
1154
+ try {
1155
+ const externalBridge = this.getExternalBridge(externalBridgeType);
1156
+ const quote = await externalBridge.quote({
1157
+ fromChain: sourceChainId,
1158
+ toChain: targetChainId,
1159
+ fromToken: fromTokenAddress,
1160
+ toToken: toTokenAddress,
1161
+ fromAmount: effectiveAmount,
1162
+ fromAddress: this.config.inventorySigner,
1163
+ toAddress: this.config.inventorySigner,
1164
+ });
1165
+
1166
+ const inputRequired = quote.fromAmount;
1167
+
1168
+ this.logger.info(
1169
+ {
1170
+ sourceChain,
1171
+ targetChain,
1172
+ sourceChainId,
1173
+ targetChainId,
1174
+ preValidatedAmount: amount.toString(),
1175
+ preValidatedAmountEth: (Number(amount) / 1e18).toFixed(6),
1176
+ effectiveAmount: effectiveAmount.toString(),
1177
+ effectiveAmountEth: (Number(effectiveAmount) / 1e18).toFixed(6),
1178
+ inputRequired: inputRequired.toString(),
1179
+ expectedOutput: quote.toAmount.toString(),
1180
+ expectedOutputEth: (Number(quote.toAmount) / 1e18).toFixed(6),
1181
+ gasCosts: quote.gasCosts.toString(),
1182
+ feeCosts: quote.feeCosts.toString(),
1183
+ intentId: intent.id,
1184
+ adjustedForMinViable: effectiveAmount > amount,
1185
+ },
1186
+ 'Executing inventory movement via LiFi with pre-validated amount',
1187
+ );
1188
+
1189
+ this.logger.debug(
1190
+ {
1191
+ quoteId: quote.id,
1192
+ tool: quote.tool,
1193
+ fromAmount: quote.fromAmount.toString(),
1194
+ toAmount: quote.toAmount.toString(),
1195
+ toAmountMin: quote.toAmountMin.toString(),
1196
+ executionDuration: quote.executionDuration,
1197
+ gasCosts: quote.gasCosts.toString(),
1198
+ feeCosts: quote.feeCosts.toString(),
1199
+ },
1200
+ 'Received LiFi quote for inventory movement',
1201
+ );
1202
+
1203
+ const signingProvider =
1204
+ this.config.inventoryMultiProvider ?? this.multiProvider;
1205
+ const signer = signingProvider.getSigner(sourceChain);
1206
+ assert(
1207
+ signer instanceof Wallet,
1208
+ `External bridge execution requires a Wallet signer with private key access, got ${signer.constructor.name}`,
1209
+ );
1210
+
1211
+ const result = await externalBridge.execute(quote, signer.privateKey);
1212
+
1213
+ this.logger.info(
1214
+ {
1215
+ sourceChain,
1216
+ targetChain,
1217
+ txHash: result.txHash,
1218
+ intentId: intent.id,
1219
+ },
1220
+ 'Inventory movement transaction executed',
1221
+ );
1222
+
1223
+ await this.actionTracker.createRebalanceAction({
1224
+ intentId: intent.id,
1225
+ origin: this.multiProvider.getDomainId(sourceChain),
1226
+ destination: this.multiProvider.getDomainId(targetChain),
1227
+ amount: inputRequired,
1228
+ type: 'inventory_movement',
1229
+ txHash: result.txHash,
1230
+ externalBridgeId: externalBridgeType,
1231
+ });
1232
+
1233
+ // Track consumed inventory on source chain for this cycle
1234
+ const currentConsumed = this.consumedInventory.get(sourceChain) ?? 0n;
1235
+ this.consumedInventory.set(sourceChain, currentConsumed + inputRequired);
1236
+
1237
+ this.logger.debug(
1238
+ {
1239
+ sourceChain,
1240
+ amountConsumed: inputRequired.toString(),
1241
+ totalConsumed: (currentConsumed + inputRequired).toString(),
1242
+ },
1243
+ 'Updated consumed inventory after LiFi bridge',
1244
+ );
1245
+
1246
+ return { success: true, txHash: result.txHash };
1247
+ } catch (error) {
1248
+ this.logger.error(
1249
+ {
1250
+ sourceChain,
1251
+ targetChain,
1252
+ amount: amount.toString(),
1253
+ intentId: intent.id,
1254
+ error: (error as Error).message,
1255
+ },
1256
+ 'Failed to execute inventory movement',
1257
+ );
1258
+
1259
+ return {
1260
+ success: false,
1261
+ error: (error as Error).message,
1262
+ };
1263
+ }
1264
+ }
1265
+ }