@humanode-hyperlane/rebalancer 0.0.0-stage → 27.2.2

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 (439) hide show
  1. package/LICENSE.md +193 -0
  2. package/README.md +297 -2
  3. package/dist/bridges/LiFiBridge.d.ts +93 -0
  4. package/dist/bridges/LiFiBridge.d.ts.map +1 -0
  5. package/dist/bridges/LiFiBridge.js +527 -0
  6. package/dist/bridges/LiFiBridge.js.map +1 -0
  7. package/dist/bridges/LiFiBridge.test.d.ts +2 -0
  8. package/dist/bridges/LiFiBridge.test.d.ts.map +1 -0
  9. package/dist/bridges/LiFiBridge.test.js +504 -0
  10. package/dist/bridges/LiFiBridge.test.js.map +1 -0
  11. package/dist/config/RebalancerConfig.d.ts +20 -0
  12. package/dist/config/RebalancerConfig.d.ts.map +1 -0
  13. package/dist/config/RebalancerConfig.js +35 -0
  14. package/dist/config/RebalancerConfig.js.map +1 -0
  15. package/dist/config/RebalancerConfig.test.d.ts +2 -0
  16. package/dist/config/RebalancerConfig.test.d.ts.map +1 -0
  17. package/dist/config/RebalancerConfig.test.js +798 -0
  18. package/dist/config/RebalancerConfig.test.js.map +1 -0
  19. package/dist/config/types.d.ts +3008 -0
  20. package/dist/config/types.d.ts.map +1 -0
  21. package/dist/config/types.js +451 -0
  22. package/dist/config/types.js.map +1 -0
  23. package/dist/core/InventoryRebalancer.d.ts +201 -0
  24. package/dist/core/InventoryRebalancer.d.ts.map +1 -0
  25. package/dist/core/InventoryRebalancer.js +994 -0
  26. package/dist/core/InventoryRebalancer.js.map +1 -0
  27. package/dist/core/InventoryRebalancer.test.d.ts +2 -0
  28. package/dist/core/InventoryRebalancer.test.d.ts.map +1 -0
  29. package/dist/core/InventoryRebalancer.test.js +1511 -0
  30. package/dist/core/InventoryRebalancer.test.js.map +1 -0
  31. package/dist/core/Rebalancer.d.ts +37 -0
  32. package/dist/core/Rebalancer.d.ts.map +1 -0
  33. package/dist/core/Rebalancer.js +433 -0
  34. package/dist/core/Rebalancer.js.map +1 -0
  35. package/dist/core/Rebalancer.test.d.ts +2 -0
  36. package/dist/core/Rebalancer.test.d.ts.map +1 -0
  37. package/dist/core/Rebalancer.test.js +424 -0
  38. package/dist/core/Rebalancer.test.js.map +1 -0
  39. package/dist/core/RebalancerOrchestrator.d.ts +56 -0
  40. package/dist/core/RebalancerOrchestrator.d.ts.map +1 -0
  41. package/dist/core/RebalancerOrchestrator.js +154 -0
  42. package/dist/core/RebalancerOrchestrator.js.map +1 -0
  43. package/dist/core/RebalancerOrchestrator.test.d.ts +2 -0
  44. package/dist/core/RebalancerOrchestrator.test.d.ts.map +1 -0
  45. package/dist/core/RebalancerOrchestrator.test.js +721 -0
  46. package/dist/core/RebalancerOrchestrator.test.js.map +1 -0
  47. package/dist/core/RebalancerService.d.ts +127 -0
  48. package/dist/core/RebalancerService.d.ts.map +1 -0
  49. package/dist/core/RebalancerService.js +270 -0
  50. package/dist/core/RebalancerService.js.map +1 -0
  51. package/dist/core/RebalancerService.test.d.ts +2 -0
  52. package/dist/core/RebalancerService.test.d.ts.map +1 -0
  53. package/dist/core/RebalancerService.test.js +777 -0
  54. package/dist/core/RebalancerService.test.js.map +1 -0
  55. package/dist/e2e/collateral-deficit.e2e-test.d.ts +2 -0
  56. package/dist/e2e/collateral-deficit.e2e-test.d.ts.map +1 -0
  57. package/dist/e2e/collateral-deficit.e2e-test.js +227 -0
  58. package/dist/e2e/collateral-deficit.e2e-test.js.map +1 -0
  59. package/dist/e2e/composite.e2e-test.d.ts +2 -0
  60. package/dist/e2e/composite.e2e-test.d.ts.map +1 -0
  61. package/dist/e2e/composite.e2e-test.js +666 -0
  62. package/dist/e2e/composite.e2e-test.js.map +1 -0
  63. package/dist/e2e/fixtures/routes.d.ts +89 -0
  64. package/dist/e2e/fixtures/routes.d.ts.map +1 -0
  65. package/dist/e2e/fixtures/routes.js +420 -0
  66. package/dist/e2e/fixtures/routes.js.map +1 -0
  67. package/dist/e2e/harness/BaseLocalDeploymentManager.d.ts +38 -0
  68. package/dist/e2e/harness/BaseLocalDeploymentManager.d.ts.map +1 -0
  69. package/dist/e2e/harness/BaseLocalDeploymentManager.js +144 -0
  70. package/dist/e2e/harness/BaseLocalDeploymentManager.js.map +1 -0
  71. package/dist/e2e/harness/BridgeSetup.d.ts +11 -0
  72. package/dist/e2e/harness/BridgeSetup.d.ts.map +1 -0
  73. package/dist/e2e/harness/BridgeSetup.js +50 -0
  74. package/dist/e2e/harness/BridgeSetup.js.map +1 -0
  75. package/dist/e2e/harness/Erc20InventoryLocalDeploymentManager.d.ts +13 -0
  76. package/dist/e2e/harness/Erc20InventoryLocalDeploymentManager.d.ts.map +1 -0
  77. package/dist/e2e/harness/Erc20InventoryLocalDeploymentManager.js +105 -0
  78. package/dist/e2e/harness/Erc20InventoryLocalDeploymentManager.js.map +1 -0
  79. package/dist/e2e/harness/Erc20LocalDeploymentManager.d.ts +11 -0
  80. package/dist/e2e/harness/Erc20LocalDeploymentManager.d.ts.map +1 -0
  81. package/dist/e2e/harness/Erc20LocalDeploymentManager.js +105 -0
  82. package/dist/e2e/harness/Erc20LocalDeploymentManager.js.map +1 -0
  83. package/dist/e2e/harness/ForkIndexer.d.ts +23 -0
  84. package/dist/e2e/harness/ForkIndexer.d.ts.map +1 -0
  85. package/dist/e2e/harness/ForkIndexer.js +127 -0
  86. package/dist/e2e/harness/ForkIndexer.js.map +1 -0
  87. package/dist/e2e/harness/ForkIndexer.test.d.ts +2 -0
  88. package/dist/e2e/harness/ForkIndexer.test.d.ts.map +1 -0
  89. package/dist/e2e/harness/ForkIndexer.test.js +257 -0
  90. package/dist/e2e/harness/ForkIndexer.test.js.map +1 -0
  91. package/dist/e2e/harness/MixedLocalDeploymentManager.d.ts +21 -0
  92. package/dist/e2e/harness/MixedLocalDeploymentManager.d.ts.map +1 -0
  93. package/dist/e2e/harness/MixedLocalDeploymentManager.js +115 -0
  94. package/dist/e2e/harness/MixedLocalDeploymentManager.js.map +1 -0
  95. package/dist/e2e/harness/MockExplorerClient.d.ts +23 -0
  96. package/dist/e2e/harness/MockExplorerClient.d.ts.map +1 -0
  97. package/dist/e2e/harness/MockExplorerClient.js +57 -0
  98. package/dist/e2e/harness/MockExplorerClient.js.map +1 -0
  99. package/dist/e2e/harness/MockExternalBridge.d.ts +32 -0
  100. package/dist/e2e/harness/MockExternalBridge.d.ts.map +1 -0
  101. package/dist/e2e/harness/MockExternalBridge.js +247 -0
  102. package/dist/e2e/harness/MockExternalBridge.js.map +1 -0
  103. package/dist/e2e/harness/NativeLocalDeploymentManager.d.ts +13 -0
  104. package/dist/e2e/harness/NativeLocalDeploymentManager.d.ts.map +1 -0
  105. package/dist/e2e/harness/NativeLocalDeploymentManager.js +91 -0
  106. package/dist/e2e/harness/NativeLocalDeploymentManager.js.map +1 -0
  107. package/dist/e2e/harness/SnapshotHelper.d.ts +13 -0
  108. package/dist/e2e/harness/SnapshotHelper.d.ts.map +1 -0
  109. package/dist/e2e/harness/SnapshotHelper.js +36 -0
  110. package/dist/e2e/harness/SnapshotHelper.js.map +1 -0
  111. package/dist/e2e/harness/TestHelpers.d.ts +25 -0
  112. package/dist/e2e/harness/TestHelpers.d.ts.map +1 -0
  113. package/dist/e2e/harness/TestHelpers.js +103 -0
  114. package/dist/e2e/harness/TestHelpers.js.map +1 -0
  115. package/dist/e2e/harness/TestRebalancer.d.ts +85 -0
  116. package/dist/e2e/harness/TestRebalancer.d.ts.map +1 -0
  117. package/dist/e2e/harness/TestRebalancer.js +427 -0
  118. package/dist/e2e/harness/TestRebalancer.js.map +1 -0
  119. package/dist/e2e/harness/TransferHelper.d.ts +34 -0
  120. package/dist/e2e/harness/TransferHelper.d.ts.map +1 -0
  121. package/dist/e2e/harness/TransferHelper.js +93 -0
  122. package/dist/e2e/harness/TransferHelper.js.map +1 -0
  123. package/dist/e2e/inventory-erc20-minAmount.e2e-test.d.ts +2 -0
  124. package/dist/e2e/inventory-erc20-minAmount.e2e-test.d.ts.map +1 -0
  125. package/dist/e2e/inventory-erc20-minAmount.e2e-test.js +411 -0
  126. package/dist/e2e/inventory-erc20-minAmount.e2e-test.js.map +1 -0
  127. package/dist/e2e/inventory-erc20-weighted.e2e-test.d.ts +2 -0
  128. package/dist/e2e/inventory-erc20-weighted.e2e-test.d.ts.map +1 -0
  129. package/dist/e2e/inventory-erc20-weighted.e2e-test.js +408 -0
  130. package/dist/e2e/inventory-erc20-weighted.e2e-test.js.map +1 -0
  131. package/dist/e2e/inventory-minAmount.e2e-test.d.ts +2 -0
  132. package/dist/e2e/inventory-minAmount.e2e-test.d.ts.map +1 -0
  133. package/dist/e2e/inventory-minAmount.e2e-test.js +411 -0
  134. package/dist/e2e/inventory-minAmount.e2e-test.js.map +1 -0
  135. package/dist/e2e/inventory-weighted.e2e-test.d.ts +2 -0
  136. package/dist/e2e/inventory-weighted.e2e-test.d.ts.map +1 -0
  137. package/dist/e2e/inventory-weighted.e2e-test.js +409 -0
  138. package/dist/e2e/inventory-weighted.e2e-test.js.map +1 -0
  139. package/dist/e2e/minAmount.e2e-test.d.ts +2 -0
  140. package/dist/e2e/minAmount.e2e-test.d.ts.map +1 -0
  141. package/dist/e2e/minAmount.e2e-test.js +203 -0
  142. package/dist/e2e/minAmount.e2e-test.js.map +1 -0
  143. package/dist/e2e/mixed-weighted.e2e-test.d.ts +2 -0
  144. package/dist/e2e/mixed-weighted.e2e-test.d.ts.map +1 -0
  145. package/dist/e2e/mixed-weighted.e2e-test.js +189 -0
  146. package/dist/e2e/mixed-weighted.e2e-test.js.map +1 -0
  147. package/dist/e2e/weighted.e2e-test.d.ts +2 -0
  148. package/dist/e2e/weighted.e2e-test.d.ts.map +1 -0
  149. package/dist/e2e/weighted.e2e-test.js +198 -0
  150. package/dist/e2e/weighted.e2e-test.js.map +1 -0
  151. package/dist/factories/RebalancerContextFactory.d.ts +89 -0
  152. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -0
  153. package/dist/factories/RebalancerContextFactory.js +420 -0
  154. package/dist/factories/RebalancerContextFactory.js.map +1 -0
  155. package/dist/factories/RebalancerContextFactory.test.d.ts +2 -0
  156. package/dist/factories/RebalancerContextFactory.test.d.ts.map +1 -0
  157. package/dist/factories/RebalancerContextFactory.test.js +270 -0
  158. package/dist/factories/RebalancerContextFactory.test.js.map +1 -0
  159. package/dist/index.d.ts +37 -0
  160. package/dist/index.d.ts.map +1 -0
  161. package/dist/index.js +37 -0
  162. package/dist/index.js.map +1 -0
  163. package/dist/interfaces/IExternalBridge.d.ts +103 -0
  164. package/dist/interfaces/IExternalBridge.d.ts.map +1 -0
  165. package/dist/interfaces/IExternalBridge.js +2 -0
  166. package/dist/interfaces/IExternalBridge.js.map +1 -0
  167. package/dist/interfaces/IMetrics.d.ts +5 -0
  168. package/dist/interfaces/IMetrics.d.ts.map +1 -0
  169. package/dist/interfaces/IMetrics.js +2 -0
  170. package/dist/interfaces/IMetrics.js.map +1 -0
  171. package/dist/interfaces/IMonitor.d.ts +50 -0
  172. package/dist/interfaces/IMonitor.d.ts.map +1 -0
  173. package/dist/interfaces/IMonitor.js +14 -0
  174. package/dist/interfaces/IMonitor.js.map +1 -0
  175. package/dist/interfaces/IRebalancer.d.ts +31 -0
  176. package/dist/interfaces/IRebalancer.d.ts.map +1 -0
  177. package/dist/interfaces/IRebalancer.js +2 -0
  178. package/dist/interfaces/IRebalancer.js.map +1 -0
  179. package/dist/interfaces/IStrategy.d.ts +60 -0
  180. package/dist/interfaces/IStrategy.d.ts.map +1 -0
  181. package/dist/interfaces/IStrategy.js +13 -0
  182. package/dist/interfaces/IStrategy.js.map +1 -0
  183. package/dist/metrics/Metrics.d.ts +25 -0
  184. package/dist/metrics/Metrics.d.ts.map +1 -0
  185. package/dist/metrics/Metrics.js +164 -0
  186. package/dist/metrics/Metrics.js.map +1 -0
  187. package/dist/metrics/PriceGetter.d.ts +10 -0
  188. package/dist/metrics/PriceGetter.d.ts.map +1 -0
  189. package/dist/metrics/PriceGetter.js +41 -0
  190. package/dist/metrics/PriceGetter.js.map +1 -0
  191. package/dist/metrics/scripts/metrics.d.ts +28 -0
  192. package/dist/metrics/scripts/metrics.d.ts.map +1 -0
  193. package/dist/metrics/scripts/metrics.js +61 -0
  194. package/dist/metrics/scripts/metrics.js.map +1 -0
  195. package/dist/monitor/Monitor.d.ts +40 -0
  196. package/dist/monitor/Monitor.d.ts.map +1 -0
  197. package/dist/monitor/Monitor.js +211 -0
  198. package/dist/monitor/Monitor.js.map +1 -0
  199. package/dist/service.d.ts +3 -0
  200. package/dist/service.d.ts.map +1 -0
  201. package/dist/service.js +205 -0
  202. package/dist/service.js.map +1 -0
  203. package/dist/strategy/BaseStrategy.d.ts +86 -0
  204. package/dist/strategy/BaseStrategy.d.ts.map +1 -0
  205. package/dist/strategy/BaseStrategy.js +342 -0
  206. package/dist/strategy/BaseStrategy.js.map +1 -0
  207. package/dist/strategy/CollateralDeficitStrategy.d.ts +65 -0
  208. package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -0
  209. package/dist/strategy/CollateralDeficitStrategy.js +253 -0
  210. package/dist/strategy/CollateralDeficitStrategy.js.map +1 -0
  211. package/dist/strategy/CollateralDeficitStrategy.test.d.ts +2 -0
  212. package/dist/strategy/CollateralDeficitStrategy.test.d.ts.map +1 -0
  213. package/dist/strategy/CollateralDeficitStrategy.test.js +497 -0
  214. package/dist/strategy/CollateralDeficitStrategy.test.js.map +1 -0
  215. package/dist/strategy/CompositeStrategy.d.ts +18 -0
  216. package/dist/strategy/CompositeStrategy.d.ts.map +1 -0
  217. package/dist/strategy/CompositeStrategy.js +63 -0
  218. package/dist/strategy/CompositeStrategy.js.map +1 -0
  219. package/dist/strategy/CompositeStrategy.test.d.ts +2 -0
  220. package/dist/strategy/CompositeStrategy.test.d.ts.map +1 -0
  221. package/dist/strategy/CompositeStrategy.test.js +278 -0
  222. package/dist/strategy/CompositeStrategy.test.js.map +1 -0
  223. package/dist/strategy/MinAmountStrategy.d.ts +35 -0
  224. package/dist/strategy/MinAmountStrategy.d.ts.map +1 -0
  225. package/dist/strategy/MinAmountStrategy.js +112 -0
  226. package/dist/strategy/MinAmountStrategy.js.map +1 -0
  227. package/dist/strategy/MinAmountStrategy.test.d.ts +2 -0
  228. package/dist/strategy/MinAmountStrategy.test.d.ts.map +1 -0
  229. package/dist/strategy/MinAmountStrategy.test.js +547 -0
  230. package/dist/strategy/MinAmountStrategy.test.js.map +1 -0
  231. package/dist/strategy/StrategyFactory.d.ts +27 -0
  232. package/dist/strategy/StrategyFactory.d.ts.map +1 -0
  233. package/dist/strategy/StrategyFactory.js +79 -0
  234. package/dist/strategy/StrategyFactory.js.map +1 -0
  235. package/dist/strategy/StrategyFactory.test.d.ts +2 -0
  236. package/dist/strategy/StrategyFactory.test.d.ts.map +1 -0
  237. package/dist/strategy/StrategyFactory.test.js +81 -0
  238. package/dist/strategy/StrategyFactory.test.js.map +1 -0
  239. package/dist/strategy/WeightedStrategy.d.ts +32 -0
  240. package/dist/strategy/WeightedStrategy.d.ts.map +1 -0
  241. package/dist/strategy/WeightedStrategy.js +73 -0
  242. package/dist/strategy/WeightedStrategy.js.map +1 -0
  243. package/dist/strategy/WeightedStrategy.test.d.ts +2 -0
  244. package/dist/strategy/WeightedStrategy.test.d.ts.map +1 -0
  245. package/dist/strategy/WeightedStrategy.test.js +403 -0
  246. package/dist/strategy/WeightedStrategy.test.js.map +1 -0
  247. package/dist/strategy/index.d.ts +7 -0
  248. package/dist/strategy/index.d.ts.map +1 -0
  249. package/dist/strategy/index.js +7 -0
  250. package/dist/strategy/index.js.map +1 -0
  251. package/dist/test/helpers.d.ts +99 -0
  252. package/dist/test/helpers.d.ts.map +1 -0
  253. package/dist/test/helpers.js +311 -0
  254. package/dist/test/helpers.js.map +1 -0
  255. package/dist/test/lifiMocks.d.ts +52 -0
  256. package/dist/test/lifiMocks.d.ts.map +1 -0
  257. package/dist/test/lifiMocks.js +160 -0
  258. package/dist/test/lifiMocks.js.map +1 -0
  259. package/dist/tracking/ActionTracker.d.ts +90 -0
  260. package/dist/tracking/ActionTracker.d.ts.map +1 -0
  261. package/dist/tracking/ActionTracker.js +678 -0
  262. package/dist/tracking/ActionTracker.js.map +1 -0
  263. package/dist/tracking/ActionTracker.test.d.ts +2 -0
  264. package/dist/tracking/ActionTracker.test.d.ts.map +1 -0
  265. package/dist/tracking/ActionTracker.test.js +1351 -0
  266. package/dist/tracking/ActionTracker.test.js.map +1 -0
  267. package/dist/tracking/IActionTracker.d.ts +157 -0
  268. package/dist/tracking/IActionTracker.d.ts.map +1 -0
  269. package/dist/tracking/IActionTracker.js +2 -0
  270. package/dist/tracking/IActionTracker.js.map +1 -0
  271. package/dist/tracking/InflightContextAdapter.d.ts +18 -0
  272. package/dist/tracking/InflightContextAdapter.d.ts.map +1 -0
  273. package/dist/tracking/InflightContextAdapter.js +52 -0
  274. package/dist/tracking/InflightContextAdapter.js.map +1 -0
  275. package/dist/tracking/InflightContextAdapter.test.d.ts +2 -0
  276. package/dist/tracking/InflightContextAdapter.test.d.ts.map +1 -0
  277. package/dist/tracking/InflightContextAdapter.test.js +175 -0
  278. package/dist/tracking/InflightContextAdapter.test.js.map +1 -0
  279. package/dist/tracking/store/IStore.d.ts +41 -0
  280. package/dist/tracking/store/IStore.d.ts.map +1 -0
  281. package/dist/tracking/store/IStore.js +2 -0
  282. package/dist/tracking/store/IStore.js.map +1 -0
  283. package/dist/tracking/store/InMemoryStore.d.ts +21 -0
  284. package/dist/tracking/store/InMemoryStore.d.ts.map +1 -0
  285. package/dist/tracking/store/InMemoryStore.js +40 -0
  286. package/dist/tracking/store/InMemoryStore.js.map +1 -0
  287. package/dist/tracking/store/InMemoryStore.test.d.ts +2 -0
  288. package/dist/tracking/store/InMemoryStore.test.d.ts.map +1 -0
  289. package/dist/tracking/store/InMemoryStore.test.js +290 -0
  290. package/dist/tracking/store/InMemoryStore.test.js.map +1 -0
  291. package/dist/tracking/store/index.d.ts +3 -0
  292. package/dist/tracking/store/index.d.ts.map +1 -0
  293. package/dist/tracking/store/index.js +2 -0
  294. package/dist/tracking/store/index.js.map +1 -0
  295. package/dist/tracking/types.d.ts +77 -0
  296. package/dist/tracking/types.d.ts.map +1 -0
  297. package/dist/tracking/types.js +2 -0
  298. package/dist/tracking/types.js.map +1 -0
  299. package/dist/utils/ExplorerClient.d.ts +68 -0
  300. package/dist/utils/ExplorerClient.d.ts.map +1 -0
  301. package/dist/utils/ExplorerClient.js +324 -0
  302. package/dist/utils/ExplorerClient.js.map +1 -0
  303. package/dist/utils/ExplorerClient.test.d.ts +2 -0
  304. package/dist/utils/ExplorerClient.test.d.ts.map +1 -0
  305. package/dist/utils/ExplorerClient.test.js +261 -0
  306. package/dist/utils/ExplorerClient.test.js.map +1 -0
  307. package/dist/utils/balanceUtils.d.ts +12 -0
  308. package/dist/utils/balanceUtils.d.ts.map +1 -0
  309. package/dist/utils/balanceUtils.js +40 -0
  310. package/dist/utils/balanceUtils.js.map +1 -0
  311. package/dist/utils/balanceUtils.test.d.ts +2 -0
  312. package/dist/utils/balanceUtils.test.d.ts.map +1 -0
  313. package/dist/utils/balanceUtils.test.js +55 -0
  314. package/dist/utils/balanceUtils.test.js.map +1 -0
  315. package/dist/utils/blockTag.d.ts +14 -0
  316. package/dist/utils/blockTag.d.ts.map +1 -0
  317. package/dist/utils/blockTag.js +46 -0
  318. package/dist/utils/blockTag.js.map +1 -0
  319. package/dist/utils/bridgeUtils.d.ts +40 -0
  320. package/dist/utils/bridgeUtils.d.ts.map +1 -0
  321. package/dist/utils/bridgeUtils.js +54 -0
  322. package/dist/utils/bridgeUtils.js.map +1 -0
  323. package/dist/utils/bridgeUtils.test.d.ts +2 -0
  324. package/dist/utils/bridgeUtils.test.d.ts.map +1 -0
  325. package/dist/utils/bridgeUtils.test.js +163 -0
  326. package/dist/utils/bridgeUtils.test.js.map +1 -0
  327. package/dist/utils/errors.d.ts +4 -0
  328. package/dist/utils/errors.d.ts.map +1 -0
  329. package/dist/utils/errors.js +6 -0
  330. package/dist/utils/errors.js.map +1 -0
  331. package/dist/utils/gasEstimation.d.ts +65 -0
  332. package/dist/utils/gasEstimation.d.ts.map +1 -0
  333. package/dist/utils/gasEstimation.js +206 -0
  334. package/dist/utils/gasEstimation.js.map +1 -0
  335. package/dist/utils/solanaKeyParser.d.ts +10 -0
  336. package/dist/utils/solanaKeyParser.d.ts.map +1 -0
  337. package/dist/utils/solanaKeyParser.js +87 -0
  338. package/dist/utils/solanaKeyParser.js.map +1 -0
  339. package/dist/utils/solanaKeyParser.test.d.ts +2 -0
  340. package/dist/utils/solanaKeyParser.test.d.ts.map +1 -0
  341. package/dist/utils/solanaKeyParser.test.js +71 -0
  342. package/dist/utils/solanaKeyParser.test.js.map +1 -0
  343. package/dist/utils/tokenUtils.d.ts +38 -0
  344. package/dist/utils/tokenUtils.d.ts.map +1 -0
  345. package/dist/utils/tokenUtils.js +65 -0
  346. package/dist/utils/tokenUtils.js.map +1 -0
  347. package/dist/utils/transactionUtils.d.ts +22 -0
  348. package/dist/utils/transactionUtils.d.ts.map +1 -0
  349. package/dist/utils/transactionUtils.js +26 -0
  350. package/dist/utils/transactionUtils.js.map +1 -0
  351. package/package.json +87 -3
  352. package/src/bridges/LiFiBridge.test.ts +615 -0
  353. package/src/bridges/LiFiBridge.ts +731 -0
  354. package/src/config/RebalancerConfig.test.ts +921 -0
  355. package/src/config/RebalancerConfig.ts +63 -0
  356. package/src/config/types.ts +613 -0
  357. package/src/core/InventoryRebalancer.test.ts +1858 -0
  358. package/src/core/InventoryRebalancer.ts +1455 -0
  359. package/src/core/Rebalancer.test.ts +686 -0
  360. package/src/core/Rebalancer.ts +686 -0
  361. package/src/core/RebalancerOrchestrator.test.ts +871 -0
  362. package/src/core/RebalancerOrchestrator.ts +273 -0
  363. package/src/core/RebalancerService.test.ts +1094 -0
  364. package/src/core/RebalancerService.ts +412 -0
  365. package/src/e2e/E2E_TEST_CONVENTIONS.md +222 -0
  366. package/src/e2e/collateral-deficit.e2e-test.ts +403 -0
  367. package/src/e2e/composite.e2e-test.ts +1008 -0
  368. package/src/e2e/fixtures/routes.ts +532 -0
  369. package/src/e2e/harness/BaseLocalDeploymentManager.ts +246 -0
  370. package/src/e2e/harness/BridgeSetup.ts +95 -0
  371. package/src/e2e/harness/Erc20InventoryLocalDeploymentManager.ts +183 -0
  372. package/src/e2e/harness/Erc20LocalDeploymentManager.ts +190 -0
  373. package/src/e2e/harness/ForkIndexer.test.ts +448 -0
  374. package/src/e2e/harness/ForkIndexer.ts +183 -0
  375. package/src/e2e/harness/MixedLocalDeploymentManager.ts +196 -0
  376. package/src/e2e/harness/MockExplorerClient.ts +94 -0
  377. package/src/e2e/harness/MockExternalBridge.ts +411 -0
  378. package/src/e2e/harness/NativeLocalDeploymentManager.ts +138 -0
  379. package/src/e2e/harness/SnapshotHelper.ts +56 -0
  380. package/src/e2e/harness/TestHelpers.ts +169 -0
  381. package/src/e2e/harness/TestRebalancer.ts +758 -0
  382. package/src/e2e/harness/TransferHelper.ts +188 -0
  383. package/src/e2e/inventory-erc20-minAmount.e2e-test.ts +735 -0
  384. package/src/e2e/inventory-erc20-weighted.e2e-test.ts +733 -0
  385. package/src/e2e/inventory-minAmount.e2e-test.ts +734 -0
  386. package/src/e2e/inventory-weighted.e2e-test.ts +734 -0
  387. package/src/e2e/minAmount.e2e-test.ts +313 -0
  388. package/src/e2e/mixed-weighted.e2e-test.ts +267 -0
  389. package/src/e2e/weighted.e2e-test.ts +304 -0
  390. package/src/factories/RebalancerContextFactory.test.ts +394 -0
  391. package/src/factories/RebalancerContextFactory.ts +771 -0
  392. package/src/index.ts +125 -0
  393. package/src/interfaces/IExternalBridge.ts +117 -0
  394. package/src/interfaces/IMetrics.ts +5 -0
  395. package/src/interfaces/IMonitor.ts +71 -0
  396. package/src/interfaces/IRebalancer.ts +57 -0
  397. package/src/interfaces/IStrategy.ts +81 -0
  398. package/src/metrics/Metrics.ts +335 -0
  399. package/src/metrics/PriceGetter.ts +74 -0
  400. package/src/metrics/scripts/metrics.ts +139 -0
  401. package/src/monitor/Monitor.ts +324 -0
  402. package/src/service.ts +285 -0
  403. package/src/strategy/BaseStrategy.ts +553 -0
  404. package/src/strategy/CollateralDeficitStrategy.test.ts +728 -0
  405. package/src/strategy/CollateralDeficitStrategy.ts +421 -0
  406. package/src/strategy/CompositeStrategy.test.ts +418 -0
  407. package/src/strategy/CompositeStrategy.ts +102 -0
  408. package/src/strategy/MinAmountStrategy.test.ts +732 -0
  409. package/src/strategy/MinAmountStrategy.ts +200 -0
  410. package/src/strategy/StrategyFactory.test.ts +108 -0
  411. package/src/strategy/StrategyFactory.ts +157 -0
  412. package/src/strategy/WeightedStrategy.test.ts +529 -0
  413. package/src/strategy/WeightedStrategy.ts +126 -0
  414. package/src/strategy/index.ts +6 -0
  415. package/src/test/helpers.ts +477 -0
  416. package/src/test/lifiMocks.ts +211 -0
  417. package/src/tracking/ActionTracker.test.ts +1625 -0
  418. package/src/tracking/ActionTracker.ts +1019 -0
  419. package/src/tracking/IActionTracker.ts +213 -0
  420. package/src/tracking/InflightContextAdapter.test.ts +206 -0
  421. package/src/tracking/InflightContextAdapter.ts +75 -0
  422. package/src/tracking/store/IStore.ts +48 -0
  423. package/src/tracking/store/InMemoryStore.test.ts +338 -0
  424. package/src/tracking/store/InMemoryStore.ts +59 -0
  425. package/src/tracking/store/index.ts +2 -0
  426. package/src/tracking/types.ts +118 -0
  427. package/src/utils/ExplorerClient.test.ts +386 -0
  428. package/src/utils/ExplorerClient.ts +439 -0
  429. package/src/utils/balanceUtils.test.ts +79 -0
  430. package/src/utils/balanceUtils.ts +65 -0
  431. package/src/utils/blockTag.ts +68 -0
  432. package/src/utils/bridgeUtils.test.ts +193 -0
  433. package/src/utils/bridgeUtils.ts +101 -0
  434. package/src/utils/errors.ts +5 -0
  435. package/src/utils/gasEstimation.ts +306 -0
  436. package/src/utils/solanaKeyParser.test.ts +103 -0
  437. package/src/utils/solanaKeyParser.ts +117 -0
  438. package/src/utils/tokenUtils.ts +89 -0
  439. package/src/utils/transactionUtils.ts +41 -0
@@ -0,0 +1,1858 @@
1
+ import chai, { expect } from 'chai';
2
+ import chaiAsPromised from 'chai-as-promised';
3
+ import { Wallet } from 'ethers';
4
+ import { pino } from 'pino';
5
+ import Sinon, { type SinonStubbedInstance } from 'sinon';
6
+
7
+ import {
8
+ type ChainName,
9
+ type MultiProvider,
10
+ ProviderType,
11
+ TokenStandard,
12
+ WarpTxCategory,
13
+ type WarpCore,
14
+ } from '@humanode-hyperlane/sdk';
15
+ import { ProtocolType } from '@humanode-hyperlane/utils';
16
+
17
+ import { ExternalBridgeType } from '../config/types.js';
18
+ import type { IExternalBridge } from '../interfaces/IExternalBridge.js';
19
+ import type { InventoryRoute } from '../interfaces/IStrategy.js';
20
+ import { createMockBridgeQuote } from '../test/lifiMocks.js';
21
+ import type { IActionTracker } from '../tracking/IActionTracker.js';
22
+ import type { RebalanceIntent } from '../tracking/types.js';
23
+
24
+ import {
25
+ InventoryRebalancer,
26
+ type InventoryRebalancerConfig,
27
+ } from './InventoryRebalancer.js';
28
+
29
+ chai.use(chaiAsPromised);
30
+
31
+ const testLogger = pino({ level: 'silent' });
32
+
33
+ describe('InventoryRebalancer E2E', () => {
34
+ let inventoryRebalancer: InventoryRebalancer;
35
+ let config: InventoryRebalancerConfig;
36
+ let actionTracker: SinonStubbedInstance<IActionTracker>;
37
+ let bridge: SinonStubbedInstance<IExternalBridge>;
38
+ let warpCore: any;
39
+ let multiProvider: any;
40
+ let adapterStub: any;
41
+
42
+ // Test constants
43
+ const ARBITRUM_CHAIN = 'arbitrum' as ChainName;
44
+ const SOLANA_CHAIN = 'solanamainnet' as ChainName;
45
+ const ARBITRUM_DOMAIN = 42161;
46
+ const SOLANA_DOMAIN = 1399811149;
47
+ const TEST_PRIVATE_KEY =
48
+ '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
49
+ const TEST_WALLET = new Wallet(TEST_PRIVATE_KEY);
50
+ const INVENTORY_SIGNER = TEST_WALLET.address;
51
+
52
+ beforeEach(() => {
53
+ // Config
54
+ config = {
55
+ inventorySigners: {
56
+ [ProtocolType.Ethereum]: {
57
+ address: INVENTORY_SIGNER,
58
+ key: TEST_PRIVATE_KEY,
59
+ },
60
+ },
61
+ inventoryChains: [ARBITRUM_CHAIN, SOLANA_CHAIN],
62
+ };
63
+
64
+ // Mock IActionTracker
65
+ actionTracker = {
66
+ initialize: Sinon.stub(),
67
+ syncTransfers: Sinon.stub(),
68
+ syncRebalanceIntents: Sinon.stub(),
69
+ syncRebalanceActions: Sinon.stub(),
70
+ syncInventoryMovementActions: Sinon.stub(),
71
+ getInProgressTransfers: Sinon.stub(),
72
+ getTransfersByDestination: Sinon.stub(),
73
+ getActiveRebalanceIntents: Sinon.stub(),
74
+ getRebalanceIntentsByDestination: Sinon.stub(),
75
+ createRebalanceIntent: Sinon.stub(),
76
+ completeRebalanceIntent: Sinon.stub(),
77
+ cancelRebalanceIntent: Sinon.stub(),
78
+ failRebalanceIntent: Sinon.stub(),
79
+ getActionsByType: Sinon.stub(),
80
+ getInflightInventoryMovements: Sinon.stub(),
81
+ getPartiallyFulfilledInventoryIntents: Sinon.stub(),
82
+ createRebalanceAction: Sinon.stub(),
83
+ completeRebalanceAction: Sinon.stub(),
84
+ failRebalanceAction: Sinon.stub(),
85
+ logStoreContents: Sinon.stub(),
86
+ } as SinonStubbedInstance<IActionTracker>;
87
+
88
+ // Default: No active (partial) inventory intents
89
+ actionTracker.getPartiallyFulfilledInventoryIntents.resolves([]);
90
+
91
+ bridge = {
92
+ bridgeId: 'lifi',
93
+ quote: Sinon.stub(),
94
+ execute: Sinon.stub(),
95
+ getStatus: Sinon.stub(),
96
+ getNativeTokenAddress: Sinon.stub().returns(
97
+ '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE',
98
+ ),
99
+ } as unknown as SinonStubbedInstance<IExternalBridge>;
100
+
101
+ // Mock adapter for WarpCore tokens
102
+ adapterStub = {
103
+ quoteTransferRemoteGas: Sinon.stub().resolves({
104
+ igpQuote: { amount: 1000000n },
105
+ }),
106
+ populateTransferRemoteTx: Sinon.stub().resolves({
107
+ to: '0xRouterAddress',
108
+ data: '0xTransferRemoteData',
109
+ value: 1000000n,
110
+ }),
111
+ isApproveRequired: Sinon.stub().resolves(false),
112
+ isRevokeApprovalRequired: Sinon.stub().resolves(false),
113
+ populateApproveTx: Sinon.stub().resolves({
114
+ to: '0xTokenAddress',
115
+ data: '0xApproveData',
116
+ value: 0n,
117
+ }),
118
+ };
119
+
120
+ // Mock WarpCore with tokens for both chains
121
+ // Note: We need tokens for both origin (surplus) and destination (deficit) chains
122
+ // because transferRemote is called FROM destination (after direction swap)
123
+ const arbitrumToken = {
124
+ chainName: ARBITRUM_CHAIN,
125
+ standard: TokenStandard.EvmHypCollateral, // Non-native: no IGP reservation needed
126
+ addressOrDenom: '0xArbitrumToken',
127
+ collateralAddressOrDenom: '0xArbitrumCollateralERC20',
128
+ getHypAdapter: Sinon.stub().returns(adapterStub),
129
+ amount: Sinon.stub().callsFake((amt: bigint) => ({ amount: amt })),
130
+ };
131
+ const solanaToken = {
132
+ chainName: SOLANA_CHAIN,
133
+ standard: TokenStandard.EvmHypCollateral, // Non-native: no IGP reservation needed
134
+ addressOrDenom: '0xSolanaToken',
135
+ collateralAddressOrDenom: '0xSolanaCollateralERC20',
136
+ getHypAdapter: Sinon.stub().returns(adapterStub),
137
+ amount: Sinon.stub().callsFake((amt: bigint) => ({ amount: amt })),
138
+ };
139
+
140
+ warpCore = {
141
+ tokens: [arbitrumToken, solanaToken],
142
+ multiProvider: {
143
+ getProvider: Sinon.stub(),
144
+ getSigner: Sinon.stub(),
145
+ getChainMetadata: Sinon.stub().callsFake((chain: ChainName) => ({
146
+ name: chain,
147
+ protocol: ProtocolType.Ethereum,
148
+ })),
149
+ getEthersV5Provider: Sinon.stub().returns({
150
+ getTransactionReceipt: Sinon.stub().resolves({
151
+ transactionHash: '0xTransferRemoteTxHash',
152
+ logs: [],
153
+ }),
154
+ }),
155
+ },
156
+ findToken: Sinon.stub().returns(null),
157
+ getTransferRemoteTxs: Sinon.stub().resolves([
158
+ {
159
+ category: 'transfer',
160
+ type: ProviderType.EthersV5,
161
+ transaction: {
162
+ to: '0xRouterAddress',
163
+ data: '0xTransferRemoteData',
164
+ value: 1000000n,
165
+ },
166
+ },
167
+ ]),
168
+ };
169
+
170
+ // Mock provider with getFeeData for gas estimation, estimateGas for actual gas estimation,
171
+ // and waitForTransaction for confirmations
172
+ const mockProvider = {
173
+ getFeeData: Sinon.stub().resolves({
174
+ maxFeePerGas: 10000000000n, // 10 gwei
175
+ gasPrice: 10000000000n,
176
+ }),
177
+ estimateGas: Sinon.stub().resolves(300000n), // Mock gas estimate for transferRemote
178
+ waitForTransaction: Sinon.stub().resolves({
179
+ blockNumber: 100,
180
+ status: 1,
181
+ }),
182
+ };
183
+
184
+ // Mock MultiProvider
185
+ multiProvider = {
186
+ getDomainId: Sinon.stub().callsFake((chain: ChainName) => {
187
+ if (chain === ARBITRUM_CHAIN) return ARBITRUM_DOMAIN;
188
+ if (chain === SOLANA_CHAIN) return SOLANA_DOMAIN;
189
+ return 0;
190
+ }),
191
+ getChainId: Sinon.stub().callsFake((chain: ChainName) => {
192
+ if (chain === ARBITRUM_CHAIN) return 42161;
193
+ if (chain === SOLANA_CHAIN) return 1399811149;
194
+ return 0;
195
+ }),
196
+ getChainName: Sinon.stub().callsFake((domain: number) => {
197
+ if (domain === ARBITRUM_DOMAIN) return ARBITRUM_CHAIN;
198
+ if (domain === SOLANA_DOMAIN) return SOLANA_CHAIN;
199
+ return 'unknown';
200
+ }),
201
+ getChainMetadata: Sinon.stub().callsFake((chain: ChainName) => ({
202
+ name: chain,
203
+ protocol: ProtocolType.Ethereum,
204
+ blocks: { reorgPeriod: 1 }, // Quick confirmations for tests
205
+ })),
206
+ getProtocol: Sinon.stub().callsFake((_: ChainName) => {
207
+ // Return Ethereum protocol for all chains in tests
208
+ return 'ethereum';
209
+ }),
210
+ getProvider: Sinon.stub().returns(mockProvider),
211
+ getSigner: Sinon.stub().returns(TEST_WALLET),
212
+ sendTransaction: Sinon.stub().resolves({
213
+ transactionHash: '0xTransferRemoteTxHash',
214
+ logs: [], // Required for HyperlaneCore.getDispatchedMessages
215
+ }),
216
+ setSigner: Sinon.stub(),
217
+ };
218
+
219
+ // Wire toMultiProvider so EvmMultiProtocolSignerAdapter returns our mock multiProvider
220
+ warpCore.multiProvider.toMultiProvider =
221
+ Sinon.stub().returns(multiProvider);
222
+
223
+ // Create InventoryRebalancer
224
+ inventoryRebalancer = new InventoryRebalancer(
225
+ config,
226
+ actionTracker as unknown as IActionTracker,
227
+ { lifi: bridge as unknown as IExternalBridge },
228
+ warpCore as unknown as WarpCore,
229
+ multiProvider as unknown as MultiProvider,
230
+ testLogger,
231
+ );
232
+
233
+ // Set default high inventory balances - tests can override via setInventoryBalances
234
+ inventoryRebalancer.setInventoryBalances({
235
+ [ARBITRUM_CHAIN]: BigInt('1000000000000000000000'),
236
+ [SOLANA_CHAIN]: BigInt('1000000000000000000000'),
237
+ });
238
+ });
239
+
240
+ afterEach(() => {
241
+ Sinon.restore();
242
+ });
243
+
244
+ // Helper to create test routes and intents
245
+ function createTestRoute(
246
+ overrides?: Partial<InventoryRoute>,
247
+ ): InventoryRoute {
248
+ return {
249
+ origin: ARBITRUM_CHAIN,
250
+ destination: SOLANA_CHAIN,
251
+ amount: 10000000000n, // 10k USDC (6 decimals)
252
+ executionType: 'inventory',
253
+ externalBridge: ExternalBridgeType.LiFi,
254
+ ...overrides,
255
+ };
256
+ }
257
+
258
+ function createTestIntent(
259
+ overrides?: Partial<RebalanceIntent>,
260
+ ): RebalanceIntent {
261
+ const now = Date.now();
262
+ const intent: RebalanceIntent = {
263
+ id: 'intent-1',
264
+ status: 'not_started',
265
+ origin: ARBITRUM_DOMAIN,
266
+ destination: SOLANA_DOMAIN,
267
+ amount: 10000000000n,
268
+ executionMethod: 'inventory',
269
+ createdAt: now,
270
+ updatedAt: now,
271
+ ...overrides,
272
+ };
273
+
274
+ // Configure mock to return this intent when createRebalanceIntent is called
275
+ actionTracker.createRebalanceIntent.resolves(intent);
276
+
277
+ return intent;
278
+ }
279
+
280
+ describe('Basic Inventory Rebalance (Sufficient Inventory)', () => {
281
+ // NOTE: Strategy route is arbitrum (surplus) → solana (deficit)
282
+ // But execution calls transferRemote FROM solana TO arbitrum (swapped direction)
283
+ // This ADDS collateral to solana (filling deficit) and RELEASES from arbitrum (has surplus)
284
+
285
+ it('executes transferRemote when inventory is available on destination chain', async () => {
286
+ // Setup: Strategy says move from arbitrum→solana
287
+ // We need inventory on SOLANA (destination/deficit) to call transferRemote FROM there
288
+ const route = createTestRoute();
289
+ createTestIntent();
290
+
291
+ // Inventory is checked on DESTINATION (solana), not origin
292
+ inventoryRebalancer.setInventoryBalances({
293
+ [SOLANA_CHAIN]: 10000000000n,
294
+ [ARBITRUM_CHAIN]: 0n,
295
+ });
296
+
297
+ // Execute
298
+ const results = await inventoryRebalancer.rebalance([route]);
299
+
300
+ // Verify: Single successful result
301
+ expect(results).to.have.lengthOf(1);
302
+ expect(results[0].success).to.be.true;
303
+ expect(results[0].route).to.deep.equal(route);
304
+
305
+ // Verify: transferRemote was called via adapter
306
+ expect(adapterStub.quoteTransferRemoteGas.calledOnce).to.be.true;
307
+ expect(warpCore.getTransferRemoteTxs.calledOnce).to.be.true;
308
+
309
+ // Verify: Transaction was sent FROM SOLANA (destination chain, swapped)
310
+ expect(multiProvider.sendTransaction.calledOnce).to.be.true;
311
+ const [chainArg, txArg] = multiProvider.sendTransaction.firstCall.args;
312
+ expect(chainArg).to.equal(SOLANA_CHAIN); // Called FROM destination (swapped)
313
+ expect(txArg.to).to.equal('0xRouterAddress');
314
+
315
+ // Verify: inventory_deposit action was created
316
+ expect(actionTracker.createRebalanceAction.calledOnce).to.be.true;
317
+ const actionParams =
318
+ actionTracker.createRebalanceAction.firstCall.args[0];
319
+ expect(actionParams.intentId).to.equal('intent-1');
320
+ expect(actionParams.type).to.equal('inventory_deposit');
321
+ expect(actionParams.amount).to.equal(10000000000n);
322
+ expect(actionParams.txHash).to.equal('0xTransferRemoteTxHash');
323
+ });
324
+
325
+ it('executes transferRemote with correct parameters (swapped direction)', async () => {
326
+ const route = createTestRoute({ amount: 5000000000n }); // 5k USDC
327
+ createTestIntent({ amount: 5000000000n });
328
+
329
+ // Inventory checked on DESTINATION (solana)
330
+ inventoryRebalancer.setInventoryBalances({
331
+ [SOLANA_CHAIN]: 5000000000n,
332
+ [ARBITRUM_CHAIN]: 0n,
333
+ });
334
+
335
+ await inventoryRebalancer.rebalance([route]);
336
+
337
+ // Direction is SWAPPED: transferRemote from solana TO arbitrum
338
+ const txParams = warpCore.getTransferRemoteTxs.firstCall.args[0];
339
+ expect(txParams.destination).to.equal(ARBITRUM_CHAIN); // Goes TO arbitrum (swapped)
340
+ expect(txParams.sender).to.equal(INVENTORY_SIGNER);
341
+ expect(txParams.recipient).to.equal(INVENTORY_SIGNER);
342
+ expect(txParams.originTokenAmount.amount).to.equal(5000000000n);
343
+ });
344
+
345
+ it('uses IMultiProtocolSigner path for solana transfer txs', async () => {
346
+ const route = createTestRoute({ amount: 5000000000n });
347
+ createTestIntent({ amount: 5000000000n });
348
+
349
+ inventoryRebalancer.setInventoryBalances({
350
+ [SOLANA_CHAIN]: 5000000000n,
351
+ [ARBITRUM_CHAIN]: 0n,
352
+ });
353
+
354
+ config.inventorySigners[ProtocolType.Sealevel] = {
355
+ address: INVENTORY_SIGNER,
356
+ key: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32',
357
+ };
358
+
359
+ warpCore.multiProvider.getChainMetadata.callsFake((chain: ChainName) => ({
360
+ name: chain,
361
+ protocol:
362
+ chain === SOLANA_CHAIN
363
+ ? ProtocolType.Sealevel
364
+ : ProtocolType.Ethereum,
365
+ blocks: { reorgPeriod: 1 },
366
+ }));
367
+
368
+ const sendAndConfirmStub = Sinon.stub(
369
+ InventoryRebalancer.prototype as any,
370
+ 'sendAndConfirmInventoryTx',
371
+ ).resolves({ txHash: '0xSolanaTxHash' });
372
+
373
+ warpCore.multiProvider.getSolanaWeb3Provider = Sinon.stub().returns({
374
+ getTransaction: Sinon.stub().resolves({
375
+ meta: { logMessages: [] },
376
+ }),
377
+ });
378
+
379
+ multiProvider.sendTransaction.resetHistory();
380
+ warpCore.getTransferRemoteTxs.resolves([
381
+ {
382
+ category: WarpTxCategory.Transfer,
383
+ type: ProviderType.SolanaWeb3,
384
+ transaction: {} as any,
385
+ },
386
+ ]);
387
+
388
+ const results = await inventoryRebalancer.rebalance([route]);
389
+
390
+ expect(results).to.have.lengthOf(1);
391
+ expect(results[0].success).to.be.true;
392
+ expect(sendAndConfirmStub.calledOnce).to.be.true;
393
+ expect(sendAndConfirmStub.firstCall.args[0]).to.equal(SOLANA_CHAIN);
394
+ expect(multiProvider.sendTransaction.called).to.be.false;
395
+
396
+ const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
397
+ expect(actionParams.txHash).to.equal('0xSolanaTxHash');
398
+ });
399
+ });
400
+
401
+ describe('Partial Fulfillment (Insufficient Inventory)', () => {
402
+ // Partial transfers happen when maxTransferable >= minViableTransfer
403
+ // For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so partial always viable
404
+ const PARTIAL_AMOUNT = BigInt(5e15); // 0.005 ETH - above threshold
405
+ const FULL_AMOUNT = BigInt(1e16); // 0.01 ETH
406
+
407
+ it('executes partial transferRemote when maxTransferable >= minViableTransfer', async () => {
408
+ // Setup: Need 0.01 ETH, but only 0.005 ETH available on destination
409
+ // For non-native tokens, minViableTransfer = 0, so partial transfer is viable
410
+ const route = createTestRoute({ amount: FULL_AMOUNT });
411
+ createTestIntent({ amount: FULL_AMOUNT });
412
+
413
+ // Inventory checked on DESTINATION (solana) - Only 0.005 ETH available
414
+ inventoryRebalancer.setInventoryBalances({
415
+ [SOLANA_CHAIN]: PARTIAL_AMOUNT,
416
+ [ARBITRUM_CHAIN]: 0n,
417
+ });
418
+
419
+ // Execute
420
+ const results = await inventoryRebalancer.rebalance([route]);
421
+
422
+ // Verify: Success with partial amount
423
+ expect(results).to.have.lengthOf(1);
424
+ expect(results[0].success).to.be.true;
425
+
426
+ // Verify: transferRemote was called with available amount (0.005 ETH), not full amount (0.01 ETH)
427
+ const txParams = warpCore.getTransferRemoteTxs.firstCall.args[0];
428
+ expect(txParams.originTokenAmount.amount).to.equal(PARTIAL_AMOUNT);
429
+
430
+ // Verify: Action created for partial amount
431
+ const actionParams =
432
+ actionTracker.createRebalanceAction.firstCall.args[0];
433
+ expect(actionParams.amount).to.equal(PARTIAL_AMOUNT);
434
+ });
435
+
436
+ it('intent remains in_progress after partial fulfillment', async () => {
437
+ const route = createTestRoute({ amount: FULL_AMOUNT });
438
+ createTestIntent({ amount: FULL_AMOUNT });
439
+
440
+ // Inventory checked on DESTINATION (solana)
441
+ inventoryRebalancer.setInventoryBalances({
442
+ [SOLANA_CHAIN]: PARTIAL_AMOUNT,
443
+ [ARBITRUM_CHAIN]: 0n,
444
+ });
445
+
446
+ const results = await inventoryRebalancer.rebalance([route]);
447
+
448
+ // Verify: Partial transfer succeeded
449
+ expect(results[0].success).to.be.true;
450
+ });
451
+ });
452
+
453
+ describe('No Inventory Available', () => {
454
+ it('returns failure when no inventory on destination chain and no other source available', async () => {
455
+ const route = createTestRoute();
456
+ createTestIntent();
457
+
458
+ // No inventory anywhere
459
+ inventoryRebalancer.setInventoryBalances({
460
+ [SOLANA_CHAIN]: 0n,
461
+ [ARBITRUM_CHAIN]: 0n,
462
+ });
463
+
464
+ // Execute
465
+ const results = await inventoryRebalancer.rebalance([route]);
466
+
467
+ // Verify: Failure result
468
+ expect(results).to.have.lengthOf(1);
469
+ expect(results[0].success).to.be.false;
470
+ expect(results[0].error).to.include('No inventory available');
471
+
472
+ // Verify: No transferRemote attempted
473
+ expect(warpCore.getTransferRemoteTxs.called).to.be.false;
474
+ expect(multiProvider.sendTransaction.called).to.be.false;
475
+ expect(actionTracker.createRebalanceAction.called).to.be.false;
476
+ });
477
+ });
478
+
479
+ describe('Single Intent Architecture', () => {
480
+ it('takes only first route when multiple routes provided', async () => {
481
+ // Route 1: arbitrum → solana (check inventory on solana)
482
+ const route1 = createTestRoute({ amount: 5000000000n });
483
+ // Route 2: solana → arbitrum (would check inventory on arbitrum if processed)
484
+ const route2 = createTestRoute({
485
+ origin: SOLANA_CHAIN,
486
+ destination: ARBITRUM_CHAIN,
487
+ amount: 3000000000n,
488
+ });
489
+
490
+ createTestIntent({ id: 'intent-1', amount: 5000000000n });
491
+
492
+ // Inventory on DESTINATION of first route (solana)
493
+ inventoryRebalancer.setInventoryBalances({
494
+ [SOLANA_CHAIN]: 5000000000n,
495
+ [ARBITRUM_CHAIN]: 0n,
496
+ });
497
+
498
+ // Execute with multiple routes
499
+ const results = await inventoryRebalancer.rebalance([route1, route2]);
500
+
501
+ // Verify: Only ONE route processed (single-intent architecture)
502
+ expect(results).to.have.lengthOf(1);
503
+ expect(results[0].success).to.be.true;
504
+
505
+ // Verify: Only one intent created
506
+ expect(actionTracker.createRebalanceIntent.calledOnce).to.be.true;
507
+
508
+ // Verify: Only one action created
509
+ expect(actionTracker.createRebalanceAction.calledOnce).to.be.true;
510
+ });
511
+
512
+ it('continues existing intent instead of processing new routes', async () => {
513
+ // Setup: existing partial intent
514
+ const existingIntent = createTestIntent({
515
+ id: 'existing-intent',
516
+ status: 'in_progress',
517
+ amount: 10000000000n,
518
+ });
519
+
520
+ // Configure mock to return existing partial intent
521
+ actionTracker.getPartiallyFulfilledInventoryIntents.resolves([
522
+ {
523
+ intent: existingIntent,
524
+ completedAmount: 3000000000n,
525
+ remaining: 7000000000n, // 7k remaining
526
+ hasInflightDeposit: false,
527
+ },
528
+ ]);
529
+
530
+ // New route that would be ignored
531
+ const newRoute = createTestRoute({ amount: 5000000000n });
532
+
533
+ // Inventory on DESTINATION of existing intent (solana) - Plenty of inventory
534
+ inventoryRebalancer.setInventoryBalances({
535
+ [SOLANA_CHAIN]: 10000000000n,
536
+ [ARBITRUM_CHAIN]: 0n,
537
+ });
538
+
539
+ // Execute with new route (should be ignored in favor of existing intent)
540
+ const results = await inventoryRebalancer.rebalance([newRoute]);
541
+
542
+ // Verify: Existing intent was continued (not new route)
543
+ expect(results).to.have.lengthOf(1);
544
+ expect(results[0].success).to.be.true;
545
+
546
+ // Verify: No new intent was created (existing was used)
547
+ expect(actionTracker.createRebalanceIntent.called).to.be.false;
548
+ });
549
+
550
+ it('returns empty when active intent has in-flight deposit (prevents oscillation)', async () => {
551
+ const existingIntent = createTestIntent({
552
+ id: 'inflight-deposit-intent',
553
+ status: 'in_progress',
554
+ amount: 10000000000n,
555
+ });
556
+
557
+ // Configure mock: active intent WITH in-flight deposit
558
+ actionTracker.getPartiallyFulfilledInventoryIntents.resolves([
559
+ {
560
+ intent: existingIntent,
561
+ completedAmount: 3000000000n,
562
+ remaining: 7000000000n,
563
+ hasInflightDeposit: true,
564
+ },
565
+ ]);
566
+
567
+ // New routes that WOULD create a new intent or continue
568
+ const newRoute = createTestRoute({ amount: 5000000000n });
569
+
570
+ inventoryRebalancer.setInventoryBalances({
571
+ [SOLANA_CHAIN]: 10000000000n,
572
+ [ARBITRUM_CHAIN]: 0n,
573
+ });
574
+
575
+ const results = await inventoryRebalancer.rebalance([newRoute]);
576
+
577
+ // Must return empty — wait for in-flight deposit to complete
578
+ expect(results).to.have.lengthOf(0);
579
+ // Must NOT create a new intent
580
+ expect(actionTracker.createRebalanceIntent.called).to.be.false;
581
+ // Must NOT call createRebalanceAction (proves continueIntent was never reached)
582
+ expect(actionTracker.createRebalanceAction.called).to.be.false;
583
+ });
584
+
585
+ it('returns empty results when no routes provided and no active intent', async () => {
586
+ const results = await inventoryRebalancer.rebalance([]);
587
+
588
+ expect(results).to.have.lengthOf(0);
589
+ expect(actionTracker.createRebalanceIntent.called).to.be.false;
590
+ });
591
+
592
+ it('continues existing not_started intent instead of creating new one', async () => {
593
+ // Setup: Create an intent that stays 'not_started' (simulating failed bridges)
594
+ const existingIntent = createTestIntent({
595
+ id: 'stuck-not-started-intent',
596
+ status: 'not_started', // Never transitioned to in_progress
597
+ amount: 10000000000n,
598
+ });
599
+
600
+ // Configure mock to return the not_started intent as a partial intent
601
+ // (this is the fix - getPartiallyFulfilledInventoryIntents now includes not_started)
602
+ actionTracker.getPartiallyFulfilledInventoryIntents.resolves([
603
+ {
604
+ intent: existingIntent,
605
+ completedAmount: 0n,
606
+ remaining: 10000000000n,
607
+ hasInflightDeposit: false,
608
+ },
609
+ ]);
610
+
611
+ // New route that would be ignored in favor of existing intent
612
+ const newRoute = createTestRoute({ amount: 5000000000n });
613
+
614
+ // Provide sufficient inventory for execution
615
+ inventoryRebalancer.setInventoryBalances({
616
+ [SOLANA_CHAIN]: 10000000000n,
617
+ [ARBITRUM_CHAIN]: 0n,
618
+ });
619
+
620
+ const results = await inventoryRebalancer.rebalance([newRoute]);
621
+
622
+ // Verify: Existing not_started intent was continued (not new route)
623
+ expect(results).to.have.lengthOf(1);
624
+ expect(results[0].success).to.be.true;
625
+
626
+ // Verify: No new intent was created (existing was continued)
627
+ expect(actionTracker.createRebalanceIntent.called).to.be.false;
628
+ });
629
+ });
630
+
631
+ describe('Error Handling', () => {
632
+ it('handles transaction send failure', async () => {
633
+ const route = createTestRoute();
634
+ createTestIntent();
635
+
636
+ // Inventory on DESTINATION (solana)
637
+ inventoryRebalancer.setInventoryBalances({
638
+ [SOLANA_CHAIN]: 10000000000n,
639
+ [ARBITRUM_CHAIN]: 0n,
640
+ });
641
+ multiProvider.sendTransaction.rejects(new Error('Transaction failed'));
642
+
643
+ const results = await inventoryRebalancer.rebalance([route]);
644
+
645
+ expect(results).to.have.lengthOf(1);
646
+ expect(results[0].success).to.be.false;
647
+ expect(results[0].error).to.include('Transaction failed');
648
+ });
649
+
650
+ it('handles missing token for chain', async () => {
651
+ // Clear tokens to simulate missing token
652
+ warpCore.tokens = [];
653
+
654
+ const route = createTestRoute();
655
+ createTestIntent();
656
+
657
+ // Even with inventory, if no token for destination, it should fail
658
+ inventoryRebalancer.setInventoryBalances({
659
+ [SOLANA_CHAIN]: 10000000000n,
660
+ [ARBITRUM_CHAIN]: 0n,
661
+ });
662
+
663
+ const results = await inventoryRebalancer.rebalance([route]);
664
+
665
+ expect(results).to.have.lengthOf(1);
666
+ expect(results[0].success).to.be.false;
667
+ expect(results[0].error).to.include('No token found');
668
+ });
669
+
670
+ it('handles adapter quoteTransferRemoteGas failure', async () => {
671
+ const route = createTestRoute();
672
+ createTestIntent();
673
+
674
+ // Inventory on DESTINATION (solana)
675
+ inventoryRebalancer.setInventoryBalances({
676
+ [SOLANA_CHAIN]: 10000000000n,
677
+ [ARBITRUM_CHAIN]: 0n,
678
+ });
679
+ adapterStub.quoteTransferRemoteGas.rejects(new Error('Gas quote failed'));
680
+
681
+ const results = await inventoryRebalancer.rebalance([route]);
682
+
683
+ expect(results).to.have.lengthOf(1);
684
+ expect(results[0].success).to.be.false;
685
+ expect(results[0].error).to.include('Gas quote failed');
686
+ });
687
+
688
+ it('throws when inventory signer key is missing', async () => {
689
+ // Config without signer keys (no key field)
690
+ const configWithoutKeys: InventoryRebalancerConfig = {
691
+ inventorySigners: {
692
+ [ProtocolType.Ethereum]: { address: INVENTORY_SIGNER },
693
+ },
694
+ inventoryChains: [ARBITRUM_CHAIN, SOLANA_CHAIN],
695
+ };
696
+
697
+ inventoryRebalancer = new InventoryRebalancer(
698
+ configWithoutKeys,
699
+ actionTracker as unknown as IActionTracker,
700
+ { lifi: bridge as unknown as IExternalBridge },
701
+ warpCore as unknown as WarpCore,
702
+ multiProvider as unknown as MultiProvider,
703
+ testLogger,
704
+ );
705
+
706
+ const route = createTestRoute({ amount: BigInt(1e18) });
707
+ createTestIntent({ amount: BigInt(1e18) });
708
+
709
+ inventoryRebalancer.setInventoryBalances({
710
+ [ARBITRUM_CHAIN]: BigInt(10e18),
711
+ [SOLANA_CHAIN]: 0n,
712
+ });
713
+
714
+ bridge.quote.resolves(
715
+ createMockBridgeQuote({
716
+ fromAmount: BigInt(1e18),
717
+ toAmount: BigInt(0.98e18),
718
+ }),
719
+ );
720
+
721
+ const results = await inventoryRebalancer.rebalance([route]);
722
+
723
+ expect(results).to.have.lengthOf(1);
724
+ expect(results[0].success).to.be.false;
725
+ expect(results[0].error).to.include('Missing inventory signer key');
726
+ expect(bridge.execute.called).to.be.false;
727
+ });
728
+ });
729
+
730
+ describe('Native Token IGP Reservation', () => {
731
+ // Gas estimation: 300,000 gas × 10 gwei = 3,000,000,000,000 wei
732
+ // Buffered gas limit (10%): 330,000 gas
733
+ // Buffered gas cost: 330,000 × 10 gwei = 3,300,000,000,000 wei
734
+ // IGP quote: 1,000,000 wei
735
+ // Total reservation: IGP + buffered gas = 3,300,001,000,000 wei (~0.0033 ETH)
736
+ // Total cost (for min viable): IGP + buffered gas = 3,300,001,000,000 wei
737
+ // Min viable transfer (2x total cost): 6,600,002,000,000 wei (~0.0066 ETH)
738
+ const GAS_LIMIT = 300000n;
739
+ const BUFFERED_GAS_LIMIT = (GAS_LIMIT * 110n) / 100n; // 10% buffer
740
+ const GAS_PRICE = 10000000000n; // 10 gwei
741
+ const BUFFERED_GAS_COST = GAS_PRICE * BUFFERED_GAS_LIMIT;
742
+ const IGP_COST = 1000000n;
743
+ const TOTAL_RESERVATION = IGP_COST + BUFFERED_GAS_COST;
744
+ // Note: MIN_VIABLE_TRANSFER = TOTAL_COST * 2n = ~6.6e12 wei (~0.0066 ETH)
745
+
746
+ it('reserves IGP and gas cost when transferring native tokens', async () => {
747
+ // Setup: Native token on DESTINATION (solana) where IGP and gas must be reserved
748
+ // Strategy: arbitrum → solana, so transferRemote is called FROM solana
749
+ const arbitrumToken = {
750
+ chainName: ARBITRUM_CHAIN,
751
+ standard: TokenStandard.EvmHypNative,
752
+ getHypAdapter: Sinon.stub().returns(adapterStub),
753
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
754
+ amount: amt,
755
+ })),
756
+ };
757
+ const solanaToken = {
758
+ chainName: SOLANA_CHAIN,
759
+ standard: TokenStandard.EvmHypNative, // Native token: reservation needed
760
+ getHypAdapter: Sinon.stub().returns(adapterStub),
761
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
762
+ amount: amt,
763
+ })),
764
+ };
765
+ warpCore.tokens = [arbitrumToken, solanaToken];
766
+
767
+ const requestedAmount = 10000000000000000n; // 0.01 ETH
768
+ const availableInventory = requestedAmount + TOTAL_RESERVATION; // Enough for amount + costs
769
+
770
+ const route = createTestRoute({ amount: requestedAmount });
771
+ createTestIntent({ amount: requestedAmount });
772
+
773
+ // Inventory on DESTINATION (solana) where transferRemote is called FROM
774
+ inventoryRebalancer.setInventoryBalances({
775
+ [SOLANA_CHAIN]: availableInventory,
776
+ [ARBITRUM_CHAIN]: 0n,
777
+ });
778
+
779
+ // Execute
780
+ const results = await inventoryRebalancer.rebalance([route]);
781
+
782
+ // Verify: Success with full requested amount (since we have enough for amount + costs)
783
+ expect(results).to.have.lengthOf(1);
784
+ expect(results[0].success).to.be.true;
785
+
786
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
787
+ expect(txParams.originTokenAmount.amount).to.equal(requestedAmount);
788
+ });
789
+
790
+ it('reduces transfer amount when inventory is limited', async () => {
791
+ // Setup: Native token on DESTINATION where we have less inventory than needed
792
+ const arbitrumToken = {
793
+ chainName: ARBITRUM_CHAIN,
794
+ standard: TokenStandard.EvmHypNative,
795
+ getHypAdapter: Sinon.stub().returns(adapterStub),
796
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
797
+ amount: amt,
798
+ })),
799
+ };
800
+ const solanaToken = {
801
+ chainName: SOLANA_CHAIN,
802
+ standard: TokenStandard.EvmHypNative,
803
+ getHypAdapter: Sinon.stub().returns(adapterStub),
804
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
805
+ amount: amt,
806
+ })),
807
+ };
808
+ warpCore.tokens = [arbitrumToken, solanaToken];
809
+
810
+ // Request more than we have available
811
+ const requestedAmount = 20000000000000000n; // 0.02 ETH
812
+ // Have enough for costs + partial transfer that exceeds min viable threshold
813
+ // availableInventory = TOTAL_RESERVATION + partialAmount
814
+ // where partialAmount >= MIN_VIABLE_TRANSFER (2x base cost)
815
+ const partialAmount = 7000000000000000n; // 0.007 ETH (> MIN_VIABLE_TRANSFER of ~0.006 ETH)
816
+ const availableInventory = TOTAL_RESERVATION + partialAmount;
817
+
818
+ const route = createTestRoute({ amount: requestedAmount });
819
+ createTestIntent({ amount: requestedAmount });
820
+
821
+ // Inventory on DESTINATION (solana)
822
+ // Since 100% is consolidated on destination, partial transfer should happen
823
+ inventoryRebalancer.setInventoryBalances({
824
+ [SOLANA_CHAIN]: availableInventory,
825
+ [ARBITRUM_CHAIN]: 0n,
826
+ });
827
+
828
+ // Execute
829
+ const results = await inventoryRebalancer.rebalance([route]);
830
+
831
+ // Verify: Success with reduced amount
832
+ expect(results).to.have.lengthOf(1);
833
+ expect(results[0].success).to.be.true;
834
+
835
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
836
+ expect(txParams.originTokenAmount.amount).to.equal(partialAmount);
837
+ });
838
+
839
+ it('returns failure when inventory cannot cover costs', async () => {
840
+ // Setup: Native token on DESTINATION where inventory is less than total reservation
841
+ const arbitrumToken = {
842
+ chainName: ARBITRUM_CHAIN,
843
+ standard: TokenStandard.EvmHypNative,
844
+ getHypAdapter: Sinon.stub().returns(adapterStub),
845
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
846
+ amount: amt,
847
+ })),
848
+ };
849
+ const solanaToken = {
850
+ chainName: SOLANA_CHAIN,
851
+ standard: TokenStandard.EvmHypNative,
852
+ getHypAdapter: Sinon.stub().returns(adapterStub),
853
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
854
+ amount: amt,
855
+ })),
856
+ };
857
+ warpCore.tokens = [arbitrumToken, solanaToken];
858
+
859
+ const route = createTestRoute({ amount: 10000000000000000n });
860
+ createTestIntent({ amount: 10000000000000000n });
861
+
862
+ // Available inventory on DESTINATION (solana) is less than total reservation (IGP + gas)
863
+ // Just under the threshold, no inventory anywhere else
864
+ inventoryRebalancer.setInventoryBalances({
865
+ [SOLANA_CHAIN]: TOTAL_RESERVATION - 1n,
866
+ [ARBITRUM_CHAIN]: 0n,
867
+ });
868
+
869
+ // Execute
870
+ const results = await inventoryRebalancer.rebalance([route]);
871
+
872
+ // Verify: Failure due to insufficient funds
873
+ expect(results).to.have.lengthOf(1);
874
+ expect(results[0].success).to.be.false;
875
+ expect(results[0].error).to.include('No inventory available');
876
+
877
+ expect(warpCore.getTransferRemoteTxs.called).to.be.false;
878
+ expect(actionTracker.createRebalanceAction.called).to.be.false;
879
+ });
880
+
881
+ it('does not reserve IGP for non-native tokens', async () => {
882
+ // Setup: Collateral tokens on both chains (IGP paid separately)
883
+ const arbitrumToken = {
884
+ chainName: ARBITRUM_CHAIN,
885
+ standard: TokenStandard.EvmHypCollateral, // Non-native: no IGP reservation
886
+ collateralAddressOrDenom: '0xArbitrumCollateralERC20',
887
+ getHypAdapter: Sinon.stub().returns(adapterStub),
888
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
889
+ amount: amt,
890
+ })),
891
+ };
892
+ const solanaToken = {
893
+ chainName: SOLANA_CHAIN,
894
+ standard: TokenStandard.EvmHypCollateral, // Non-native: no IGP reservation
895
+ collateralAddressOrDenom: '0xSolanaCollateralERC20',
896
+ getHypAdapter: Sinon.stub().returns(adapterStub),
897
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
898
+ amount: amt,
899
+ })),
900
+ };
901
+ warpCore.tokens = [arbitrumToken, solanaToken];
902
+
903
+ const route = createTestRoute({ amount: 10000000000n });
904
+ createTestIntent({ amount: 10000000000n });
905
+
906
+ // Inventory on DESTINATION (solana)
907
+ inventoryRebalancer.setInventoryBalances({
908
+ [SOLANA_CHAIN]: 10000000000n,
909
+ [ARBITRUM_CHAIN]: 0n,
910
+ });
911
+
912
+ // Execute
913
+ const results = await inventoryRebalancer.rebalance([route]);
914
+
915
+ // Verify: Success with full amount (no IGP deduction)
916
+ expect(results).to.have.lengthOf(1);
917
+ expect(results[0].success).to.be.true;
918
+
919
+ const txParams = warpCore.getTransferRemoteTxs.firstCall.args[0];
920
+ expect(txParams.originTokenAmount.amount).to.equal(10000000000n); // Full amount
921
+ });
922
+ });
923
+
924
+ describe('Native Token Fee Quote Reservation', () => {
925
+ // Token fee quote scenarios: tests verify that tokenFeeQuote is properly deducted
926
+ // from maxTransferable when it represents a native token fee
927
+ const GAS_LIMIT = 300000n;
928
+ const BUFFERED_GAS_LIMIT = (GAS_LIMIT * 110n) / 100n; // 10% buffer
929
+ const GAS_PRICE = 10000000000n; // 10 gwei
930
+ const BUFFERED_GAS_COST = GAS_PRICE * BUFFERED_GAS_LIMIT;
931
+ const IGP_COST = 1000000n;
932
+ const TOKEN_FEE_AMOUNT = 500000n; // Significant token fee
933
+ const TOTAL_RESERVATION = IGP_COST + BUFFERED_GAS_COST;
934
+
935
+ it('deducts tokenFeeQuote when addressOrDenom is undefined (native token)', async () => {
936
+ // Setup: Native token with tokenFeeQuote but no addressOrDenom (undefined = native)
937
+ // Override the global adapterStub to include tokenFeeQuote
938
+ adapterStub.quoteTransferRemoteGas.resolves({
939
+ igpQuote: { amount: IGP_COST },
940
+ tokenFeeQuote: { amount: TOKEN_FEE_AMOUNT }, // No addressOrDenom = native
941
+ });
942
+
943
+ const arbitrumToken = {
944
+ chainName: ARBITRUM_CHAIN,
945
+ standard: TokenStandard.EvmHypNative,
946
+ getHypAdapter: Sinon.stub().returns(adapterStub),
947
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
948
+ amount: amt,
949
+ })),
950
+ };
951
+ const solanaToken = {
952
+ chainName: SOLANA_CHAIN,
953
+ standard: TokenStandard.EvmHypNative,
954
+ getHypAdapter: Sinon.stub().returns(adapterStub),
955
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
956
+ amount: amt,
957
+ })),
958
+ };
959
+ warpCore.tokens = [arbitrumToken, solanaToken];
960
+
961
+ const requestedAmount = 10000000000000000n; // 0.01 ETH
962
+ // Available = requested + IGP + gas + tokenFee (enough for full transfer)
963
+ const availableInventory =
964
+ requestedAmount + TOTAL_RESERVATION + TOKEN_FEE_AMOUNT;
965
+
966
+ const route = createTestRoute({ amount: requestedAmount });
967
+ createTestIntent({ amount: requestedAmount });
968
+
969
+ inventoryRebalancer.setInventoryBalances({
970
+ [SOLANA_CHAIN]: availableInventory,
971
+ [ARBITRUM_CHAIN]: 0n,
972
+ });
973
+
974
+ const results = await inventoryRebalancer.rebalance([route]);
975
+
976
+ expect(results).to.have.lengthOf(1);
977
+ expect(results[0].success).to.be.true;
978
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
979
+ expect(txParams.originTokenAmount.amount).to.equal(requestedAmount);
980
+ });
981
+
982
+ it('deducts tokenFeeQuote when addressOrDenom is zero address', async () => {
983
+ // Setup: Native token with tokenFeeQuote and zero-address denom (isZeroishAddress)
984
+ const zeroAddressAdapter = {
985
+ quoteTransferRemoteGas: Sinon.stub().resolves({
986
+ igpQuote: { amount: IGP_COST },
987
+ tokenFeeQuote: {
988
+ addressOrDenom: '0x0000000000000000000000000000000000000000',
989
+ amount: TOKEN_FEE_AMOUNT,
990
+ },
991
+ }),
992
+ populateTransferRemoteTx: Sinon.stub().resolves({
993
+ to: '0xRouterAddress',
994
+ data: '0xTransferRemoteData',
995
+ value: 1000000n,
996
+ }),
997
+ };
998
+
999
+ const arbitrumToken = {
1000
+ chainName: ARBITRUM_CHAIN,
1001
+ standard: TokenStandard.EvmHypNative,
1002
+ getHypAdapter: Sinon.stub().returns(zeroAddressAdapter),
1003
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
1004
+ amount: amt,
1005
+ })),
1006
+ };
1007
+ const solanaToken = {
1008
+ chainName: SOLANA_CHAIN,
1009
+ standard: TokenStandard.EvmHypNative,
1010
+ getHypAdapter: Sinon.stub().returns(zeroAddressAdapter),
1011
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
1012
+ amount: amt,
1013
+ })),
1014
+ };
1015
+ warpCore.tokens = [arbitrumToken, solanaToken];
1016
+
1017
+ const requestedAmount = 10000000000000000n;
1018
+ const availableInventory =
1019
+ requestedAmount + TOTAL_RESERVATION + TOKEN_FEE_AMOUNT;
1020
+
1021
+ const route = createTestRoute({ amount: requestedAmount });
1022
+ createTestIntent({ amount: requestedAmount });
1023
+
1024
+ inventoryRebalancer.setInventoryBalances({
1025
+ [SOLANA_CHAIN]: availableInventory,
1026
+ [ARBITRUM_CHAIN]: 0n,
1027
+ });
1028
+
1029
+ const results = await inventoryRebalancer.rebalance([route]);
1030
+
1031
+ expect(results).to.have.lengthOf(1);
1032
+ expect(results[0].success).to.be.true;
1033
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
1034
+ expect(txParams.originTokenAmount.amount).to.equal(requestedAmount);
1035
+ });
1036
+
1037
+ it('does NOT deduct tokenFeeQuote when addressOrDenom is ERC20 address', async () => {
1038
+ // Setup: Native token with tokenFeeQuote but ERC20 denom (not native)
1039
+ const erc20Address = '0x1234567890abcdef1234567890abcdef12345678';
1040
+ const erc20Adapter = {
1041
+ quoteTransferRemoteGas: Sinon.stub().resolves({
1042
+ igpQuote: { amount: IGP_COST },
1043
+ tokenFeeQuote: {
1044
+ addressOrDenom: erc20Address,
1045
+ amount: TOKEN_FEE_AMOUNT,
1046
+ },
1047
+ }),
1048
+ populateTransferRemoteTx: Sinon.stub().resolves({
1049
+ to: '0xRouterAddress',
1050
+ data: '0xTransferRemoteData',
1051
+ value: 1000000n,
1052
+ }),
1053
+ };
1054
+
1055
+ const arbitrumToken = {
1056
+ chainName: ARBITRUM_CHAIN,
1057
+ standard: TokenStandard.EvmHypNative,
1058
+ getHypAdapter: Sinon.stub().returns(erc20Adapter),
1059
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
1060
+ amount: amt,
1061
+ })),
1062
+ };
1063
+ const solanaToken = {
1064
+ chainName: SOLANA_CHAIN,
1065
+ standard: TokenStandard.EvmHypNative,
1066
+ getHypAdapter: Sinon.stub().returns(erc20Adapter),
1067
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
1068
+ amount: amt,
1069
+ })),
1070
+ };
1071
+ warpCore.tokens = [arbitrumToken, solanaToken];
1072
+
1073
+ const requestedAmount = 10000000000000000n;
1074
+ // Available = requested + IGP + gas (NO tokenFee deduction since it's ERC20)
1075
+ const availableInventory = requestedAmount + TOTAL_RESERVATION;
1076
+
1077
+ const route = createTestRoute({ amount: requestedAmount });
1078
+ createTestIntent({ amount: requestedAmount });
1079
+
1080
+ inventoryRebalancer.setInventoryBalances({
1081
+ [SOLANA_CHAIN]: availableInventory,
1082
+ [ARBITRUM_CHAIN]: 0n,
1083
+ });
1084
+
1085
+ const results = await inventoryRebalancer.rebalance([route]);
1086
+
1087
+ expect(results).to.have.lengthOf(1);
1088
+ expect(results[0].success).to.be.true;
1089
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
1090
+ expect(txParams.originTokenAmount.amount).to.equal(requestedAmount);
1091
+ });
1092
+
1093
+ it('handles undefined tokenFeeQuote (backward compatibility with v<10)', async () => {
1094
+ // Setup: Native token without tokenFeeQuote (old contract version)
1095
+ const oldAdapter = {
1096
+ quoteTransferRemoteGas: Sinon.stub().resolves({
1097
+ igpQuote: { amount: IGP_COST },
1098
+ // No tokenFeeQuote field
1099
+ }),
1100
+ populateTransferRemoteTx: Sinon.stub().resolves({
1101
+ to: '0xRouterAddress',
1102
+ data: '0xTransferRemoteData',
1103
+ value: 1000000n,
1104
+ }),
1105
+ };
1106
+
1107
+ const arbitrumToken = {
1108
+ chainName: ARBITRUM_CHAIN,
1109
+ standard: TokenStandard.EvmHypNative,
1110
+ getHypAdapter: Sinon.stub().returns(oldAdapter),
1111
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
1112
+ amount: amt,
1113
+ })),
1114
+ };
1115
+ const solanaToken = {
1116
+ chainName: SOLANA_CHAIN,
1117
+ standard: TokenStandard.EvmHypNative,
1118
+ getHypAdapter: Sinon.stub().returns(oldAdapter),
1119
+ amount: Sinon.stub().callsFake((amt: bigint) => ({
1120
+ amount: amt,
1121
+ })),
1122
+ };
1123
+ warpCore.tokens = [arbitrumToken, solanaToken];
1124
+
1125
+ const requestedAmount = 10000000000000000n;
1126
+ // Available = requested + IGP + gas (no tokenFee since undefined)
1127
+ const availableInventory = requestedAmount + TOTAL_RESERVATION;
1128
+
1129
+ const route = createTestRoute({ amount: requestedAmount });
1130
+ createTestIntent({ amount: requestedAmount });
1131
+
1132
+ inventoryRebalancer.setInventoryBalances({
1133
+ [SOLANA_CHAIN]: availableInventory,
1134
+ [ARBITRUM_CHAIN]: 0n,
1135
+ });
1136
+
1137
+ const results = await inventoryRebalancer.rebalance([route]);
1138
+
1139
+ expect(results).to.have.lengthOf(1);
1140
+ expect(results[0].success).to.be.true;
1141
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
1142
+ expect(txParams.originTokenAmount.amount).to.equal(requestedAmount);
1143
+ });
1144
+
1145
+ it('reduces maxTransferable when large tokenFeeQuote is present', async () => {
1146
+ // Setup: Native token with large tokenFeeQuote that significantly reduces maxTransferable
1147
+ adapterStub.quoteTransferRemoteGas.resolves({
1148
+ igpQuote: { amount: IGP_COST },
1149
+ tokenFeeQuote: { amount: 5000000000000000n }, // Large fee (5e15 wei)
1150
+ });
1151
+
1152
+ const arbitrumToken = {
1153
+ chainName: ARBITRUM_CHAIN,
1154
+ standard: TokenStandard.EvmHypNative,
1155
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1156
+ };
1157
+ const solanaToken = {
1158
+ chainName: SOLANA_CHAIN,
1159
+ standard: TokenStandard.EvmHypNative,
1160
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1161
+ };
1162
+ warpCore.tokens = [arbitrumToken, solanaToken];
1163
+
1164
+ const requestedAmount = 10000000000000000n; // 0.01 ETH
1165
+ // Available = requested + IGP + gas + large tokenFee
1166
+ const largeTokenFee = 5000000000000000n;
1167
+ const availableInventory =
1168
+ requestedAmount + TOTAL_RESERVATION + largeTokenFee;
1169
+
1170
+ const route = createTestRoute({ amount: requestedAmount });
1171
+ createTestIntent({ amount: requestedAmount });
1172
+
1173
+ inventoryRebalancer.setInventoryBalances({
1174
+ [SOLANA_CHAIN]: availableInventory,
1175
+ [ARBITRUM_CHAIN]: 0n,
1176
+ });
1177
+
1178
+ const results = await inventoryRebalancer.rebalance([route]);
1179
+
1180
+ // Verify: Transfer succeeds with full amount (we have enough for amount + all costs)
1181
+ // This demonstrates that tokenFeeQuote is properly deducted from maxTransferable
1182
+ expect(results).to.have.lengthOf(1);
1183
+ expect(results[0].success).to.be.true;
1184
+ });
1185
+ });
1186
+
1187
+ describe('LiFi Bridge Integration (for future inventory_movement)', () => {
1188
+ // These tests validate the mock utilities are working correctly
1189
+ // and prepare for when inventory_movement is implemented
1190
+
1191
+ it('mock utilities create valid quote', () => {
1192
+ const quote = createMockBridgeQuote({
1193
+ fromAmount: 10000000000n,
1194
+ toAmount: 9950000000n,
1195
+ });
1196
+
1197
+ expect(quote.id).to.equal('quote-123');
1198
+ expect(quote.tool).to.equal('across');
1199
+ expect(quote.fromAmount).to.equal(10000000000n);
1200
+ expect(quote.toAmount).to.equal(9950000000n);
1201
+ });
1202
+
1203
+ it('bridge mock can be configured for quote', async () => {
1204
+ bridge.quote.resolves(
1205
+ createMockBridgeQuote({
1206
+ fromAmount: 10000000000n,
1207
+ toAmount: 9950000000n,
1208
+ }),
1209
+ );
1210
+
1211
+ const quote = await bridge.quote({
1212
+ fromChain: ARBITRUM_DOMAIN,
1213
+ toChain: SOLANA_DOMAIN,
1214
+ fromToken: '0xUSDC',
1215
+ toToken: '0xUSDC',
1216
+ fromAmount: 10000000000n,
1217
+ fromAddress: INVENTORY_SIGNER,
1218
+ });
1219
+
1220
+ expect(quote.fromAmount).to.equal(10000000000n);
1221
+ expect(quote.toAmount).to.equal(9950000000n);
1222
+ });
1223
+ });
1224
+
1225
+ describe('Smart Partial Transfer Threshold', () => {
1226
+ // Test the 90% consolidation threshold for partial transfers
1227
+ // Tests use non-native tokens (EvmHypCollateral) so minViableTransfer = 0
1228
+
1229
+ it('does partial transfer when inventory is available on destination', async () => {
1230
+ // amount = 1 ETH, availableOnDestination = 0.5 ETH
1231
+ // With simplified logic: if maxTransferable >= minViableTransfer, do partial transfer
1232
+ // For non-native tokens, minViableTransfer = 0, so partial transfer happens
1233
+ const amount = BigInt(1e18); // 1 ETH
1234
+ const availableOnDestination = BigInt(0.5e18); // 0.5 ETH on destination
1235
+
1236
+ const route = createTestRoute({ amount });
1237
+ createTestIntent({ amount });
1238
+
1239
+ // Inventory on destination (SOLANA)
1240
+ inventoryRebalancer.setInventoryBalances({
1241
+ [SOLANA_CHAIN]: availableOnDestination,
1242
+ [ARBITRUM_CHAIN]: 0n,
1243
+ });
1244
+
1245
+ const results = await inventoryRebalancer.rebalance([route]);
1246
+
1247
+ expect(results).to.have.lengthOf(1);
1248
+ expect(results[0].success).to.be.true;
1249
+
1250
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
1251
+ expect(txParams.originTokenAmount.amount).to.equal(
1252
+ availableOnDestination,
1253
+ );
1254
+
1255
+ // Verify: Bridge was NOT called (no need to bridge when partial transfer is viable)
1256
+ expect(bridge.execute.called).to.be.false;
1257
+ });
1258
+
1259
+ it('does partial transfer when maxTransferable >= minViableTransfer', async () => {
1260
+ // For non-native tokens (EvmHypCollateral), minViableTransfer = 0
1261
+ // So any positive maxTransferable triggers partial transfer
1262
+ const amount = BigInt(2e18); // 2 ETH requested
1263
+ const maxTransferable = BigInt(0.6e18); // 0.6 ETH available
1264
+
1265
+ const route = createTestRoute({ amount });
1266
+ createTestIntent({ amount });
1267
+
1268
+ // 0.6 ETH available on destination
1269
+ inventoryRebalancer.setInventoryBalances({
1270
+ [SOLANA_CHAIN]: maxTransferable,
1271
+ [ARBITRUM_CHAIN]: 0n,
1272
+ });
1273
+
1274
+ const results = await inventoryRebalancer.rebalance([route]);
1275
+
1276
+ expect(results).to.have.lengthOf(1);
1277
+ expect(results[0].success).to.be.true;
1278
+
1279
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
1280
+ expect(txParams.originTokenAmount.amount).to.equal(maxTransferable);
1281
+ });
1282
+
1283
+ it('does NOT do partial transfer when maxTransferable < minViableTransfer (native tokens)', async () => {
1284
+ // For native tokens (EvmHypNative), minViableTransfer = totalCost * 2
1285
+ // When available inventory minus costs is below minViableTransfer, falls through to bridging
1286
+ const arbitrumToken = {
1287
+ chainName: ARBITRUM_CHAIN,
1288
+ standard: TokenStandard.EvmHypNative,
1289
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1290
+ };
1291
+ const solanaToken = {
1292
+ chainName: SOLANA_CHAIN,
1293
+ standard: TokenStandard.EvmHypNative,
1294
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1295
+ };
1296
+ warpCore.tokens = [arbitrumToken, solanaToken];
1297
+
1298
+ const amount = BigInt(2e18); // 2 ETH
1299
+ // Available inventory is small - after subtracting costs, maxTransferable < minViableTransfer
1300
+ // minViableTransfer = ~0.0066 ETH (totalCost * 2)
1301
+ // If available = 0.005 ETH, after costs ~0, maxTransferable < minViableTransfer
1302
+ const availableOnDestination = BigInt(0.003e18); // 0.003 ETH - below minViableTransfer
1303
+ const availableOnSource = BigInt(0.6e18); // 0.6 ETH on ARBITRUM
1304
+
1305
+ const route = createTestRoute({ amount });
1306
+ createTestIntent({ amount });
1307
+
1308
+ // Inventory on destination - too small after costs, but source has inventory
1309
+ inventoryRebalancer.setInventoryBalances({
1310
+ [SOLANA_CHAIN]: availableOnDestination,
1311
+ [ARBITRUM_CHAIN]: availableOnSource,
1312
+ });
1313
+
1314
+ // Mock bridge
1315
+ bridge.quote.resolves(
1316
+ createMockBridgeQuote({
1317
+ fromAmount: BigInt(0.5e18),
1318
+ toAmount: BigInt(0.48e18),
1319
+ }),
1320
+ );
1321
+ bridge.execute.resolves({
1322
+ txHash: '0xBridgeTxHash',
1323
+ fromChain: 42161,
1324
+ toChain: 1399811149,
1325
+ });
1326
+
1327
+ const results = await inventoryRebalancer.rebalance([route]);
1328
+
1329
+ expect(results).to.have.lengthOf(1);
1330
+ expect(results[0].success).to.be.true;
1331
+
1332
+ // Verify: inventory movement via bridge happened (NOT partial transferRemote)
1333
+ expect(bridge.execute.called).to.be.true;
1334
+ });
1335
+ });
1336
+
1337
+ describe('Early Exit for Small Amounts', () => {
1338
+ // Gas estimation: 300,000 gas × 10 gwei = 3,000,000,000,000 wei
1339
+ // Buffered gas limit (10%): 330,000 gas
1340
+ // Buffered gas cost: 330,000 × 10 gwei = 3,300,000,000,000 wei
1341
+ // IGP quote: 1,000,000 wei
1342
+ // Total cost: IGP + buffered gas = 3,300,001,000,000 wei (~0.0033 ETH)
1343
+ // Min viable transfer (2x total cost): ~0.0066 ETH
1344
+ const MIN_VIABLE = BigInt(6.6e12); // ~0.0066 ETH
1345
+
1346
+ it('completes intent when amount < minViableTransfer', async () => {
1347
+ // Use native token to get non-zero minViableTransfer
1348
+ const arbitrumToken = {
1349
+ chainName: ARBITRUM_CHAIN,
1350
+ standard: TokenStandard.EvmHypNative,
1351
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1352
+ };
1353
+ const solanaToken = {
1354
+ chainName: SOLANA_CHAIN,
1355
+ standard: TokenStandard.EvmHypNative,
1356
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1357
+ };
1358
+ warpCore.tokens = [arbitrumToken, solanaToken];
1359
+
1360
+ // Amount smaller than minViableTransfer
1361
+ const smallAmount = MIN_VIABLE / 2n; // 0.0033 ETH < minViable 0.0066 ETH
1362
+ const route = createTestRoute({ amount: smallAmount });
1363
+ createTestIntent({ amount: smallAmount });
1364
+
1365
+ // Even with plenty of inventory, small amount triggers early exit
1366
+ inventoryRebalancer.setInventoryBalances({
1367
+ [SOLANA_CHAIN]: BigInt(10e18),
1368
+ [ARBITRUM_CHAIN]: BigInt(10e18),
1369
+ });
1370
+
1371
+ const results = await inventoryRebalancer.rebalance([route]);
1372
+
1373
+ expect(results).to.have.lengthOf(1);
1374
+ expect(results[0].success).to.be.true;
1375
+ expect((results[0] as any).reason).to.equal(
1376
+ 'completed_with_acceptable_loss',
1377
+ );
1378
+
1379
+ // Verify: Intent was completed (not left in progress)
1380
+ expect(actionTracker.completeRebalanceIntent.calledOnce).to.be.true;
1381
+ expect(actionTracker.completeRebalanceIntent.calledWith('intent-1')).to.be
1382
+ .true;
1383
+
1384
+ // Verify: No transferRemote was attempted
1385
+ expect(actionTracker.createRebalanceAction.called).to.be.false;
1386
+ });
1387
+ });
1388
+
1389
+ describe('Parallel Multi-Source Bridging', () => {
1390
+ // Third chain for multi-source tests
1391
+ const BASE_CHAIN = 'base' as ChainName;
1392
+ const BASE_DOMAIN = 8453;
1393
+
1394
+ beforeEach(() => {
1395
+ // Extend multiProvider to handle BASE_CHAIN
1396
+ multiProvider.getDomainId.callsFake((chain: ChainName) => {
1397
+ if (chain === ARBITRUM_CHAIN) return ARBITRUM_DOMAIN;
1398
+ if (chain === SOLANA_CHAIN) return SOLANA_DOMAIN;
1399
+ if (chain === BASE_CHAIN) return BASE_DOMAIN;
1400
+ return 0;
1401
+ });
1402
+
1403
+ multiProvider.getChainId = Sinon.stub().callsFake((chain: ChainName) => {
1404
+ if (chain === ARBITRUM_CHAIN) return 42161;
1405
+ if (chain === SOLANA_CHAIN) return 1399811149;
1406
+ if (chain === BASE_CHAIN) return 8453;
1407
+ return 0;
1408
+ });
1409
+
1410
+ // Add token for BASE_CHAIN
1411
+ const baseToken = {
1412
+ chainName: BASE_CHAIN,
1413
+ standard: TokenStandard.EvmHypCollateral,
1414
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1415
+ addressOrDenom: '0xBaseToken',
1416
+ collateralAddressOrDenom: '0xBaseCollateralERC20',
1417
+ };
1418
+ warpCore.tokens.push(baseToken);
1419
+
1420
+ multiProvider.getSigner = Sinon.stub().returns(TEST_WALLET);
1421
+ });
1422
+
1423
+ it('bridges from multiple sources in parallel', async () => {
1424
+ // Need 1 ETH on solana, have 0.6 ETH on arbitrum and 0.6 ETH on base
1425
+ const amount = BigInt(1e18);
1426
+ const perChainInventory = BigInt(0.6e18);
1427
+
1428
+ const route = createTestRoute({ amount });
1429
+ createTestIntent({ amount });
1430
+
1431
+ // No inventory on destination, inventory on sources
1432
+ inventoryRebalancer.setInventoryBalances({
1433
+ [SOLANA_CHAIN]: 0n,
1434
+ [ARBITRUM_CHAIN]: perChainInventory,
1435
+ [BASE_CHAIN]: perChainInventory,
1436
+ });
1437
+
1438
+ // Mock bridge quotes and execution
1439
+ bridge.quote.resolves(
1440
+ createMockBridgeQuote({
1441
+ fromAmount: BigInt(0.55e18),
1442
+ toAmount: BigInt(0.525e18),
1443
+ }),
1444
+ );
1445
+ bridge.execute.resolves({
1446
+ txHash: '0xBridgeTxHash',
1447
+ fromChain: 42161,
1448
+ toChain: 1399811149,
1449
+ });
1450
+
1451
+ const results = await inventoryRebalancer.rebalance([route]);
1452
+
1453
+ expect(results).to.have.lengthOf(1);
1454
+ expect(results[0].success).to.be.true;
1455
+
1456
+ // Verify: Bridge was called twice (once for each source)
1457
+ expect(bridge.execute.callCount).to.equal(2);
1458
+ });
1459
+
1460
+ it('applies 5% buffer to total bridge amount', async () => {
1461
+ // Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
1462
+ const amount = BigInt(1e18); // 1 ETH
1463
+ const availableInventory = BigInt(2e18); // 2 ETH on source
1464
+
1465
+ const route = createTestRoute({ amount });
1466
+ createTestIntent({ amount });
1467
+
1468
+ // No inventory on destination, plenty on source
1469
+ inventoryRebalancer.setInventoryBalances({
1470
+ [SOLANA_CHAIN]: 0n,
1471
+ [ARBITRUM_CHAIN]: availableInventory,
1472
+ });
1473
+
1474
+ // Capture the quote amount from executeInventoryMovement
1475
+ // (calculateMaxViableBridgeAmount doesn't quote for ERC20 tokens)
1476
+ let quotedFromAmount: bigint | undefined;
1477
+ bridge.quote.callsFake(async (params: any) => {
1478
+ quotedFromAmount = params.fromAmount;
1479
+ return createMockBridgeQuote({
1480
+ fromAmount: params.fromAmount ?? params.toAmount,
1481
+ toAmount: params.fromAmount ?? params.toAmount,
1482
+ });
1483
+ });
1484
+ bridge.execute.resolves({
1485
+ txHash: '0xBridgeTxHash',
1486
+ fromChain: 42161,
1487
+ toChain: 1399811149,
1488
+ });
1489
+
1490
+ await inventoryRebalancer.rebalance([route]);
1491
+
1492
+ // Verify: 5% buffer applied (1 ETH * 1.05 = 1.05 ETH)
1493
+ // The bridge plan uses pre-validated amounts (for ERC20, full inventory available)
1494
+ // But the target is (amount * 105%), so if source has >= target, we bridge exactly target
1495
+ const expectedWithBuffer = (amount * 105n) / 100n;
1496
+ expect(quotedFromAmount).to.equal(expectedWithBuffer);
1497
+ });
1498
+
1499
+ it('continues when some bridges fail', async () => {
1500
+ const amount = BigInt(1e18);
1501
+ const perChainInventory = BigInt(0.6e18);
1502
+
1503
+ const route = createTestRoute({ amount });
1504
+ createTestIntent({ amount });
1505
+
1506
+ // No inventory on destination, inventory on sources
1507
+ inventoryRebalancer.setInventoryBalances({
1508
+ [SOLANA_CHAIN]: 0n,
1509
+ [ARBITRUM_CHAIN]: perChainInventory,
1510
+ [BASE_CHAIN]: perChainInventory,
1511
+ });
1512
+
1513
+ bridge.quote.resolves(
1514
+ createMockBridgeQuote({
1515
+ fromAmount: BigInt(0.55e18),
1516
+ toAmount: BigInt(0.525e18),
1517
+ }),
1518
+ );
1519
+
1520
+ // First bridge succeeds, second fails
1521
+ bridge.execute
1522
+ .onFirstCall()
1523
+ .resolves({
1524
+ txHash: '0xSuccessTxHash',
1525
+ fromChain: 42161,
1526
+ toChain: 1399811149,
1527
+ })
1528
+ .onSecondCall()
1529
+ .rejects(new Error('Bridge execution failed'));
1530
+
1531
+ const results = await inventoryRebalancer.rebalance([route]);
1532
+
1533
+ // Verify: Overall success (at least one bridge succeeded)
1534
+ expect(results).to.have.lengthOf(1);
1535
+ expect(results[0].success).to.be.true;
1536
+ });
1537
+
1538
+ it('returns failure when all bridges fail', async () => {
1539
+ const amount = BigInt(1e18);
1540
+ const perChainInventory = BigInt(0.6e18);
1541
+
1542
+ const route = createTestRoute({ amount });
1543
+ createTestIntent({ amount });
1544
+
1545
+ // No inventory on destination, inventory on sources
1546
+ inventoryRebalancer.setInventoryBalances({
1547
+ [SOLANA_CHAIN]: 0n,
1548
+ [ARBITRUM_CHAIN]: perChainInventory,
1549
+ [BASE_CHAIN]: perChainInventory,
1550
+ });
1551
+
1552
+ bridge.quote.resolves(
1553
+ createMockBridgeQuote({
1554
+ fromAmount: BigInt(0.55e18),
1555
+ toAmount: BigInt(0.525e18),
1556
+ }),
1557
+ );
1558
+
1559
+ // All bridges fail
1560
+ bridge.execute.rejects(new Error('Bridge execution failed'));
1561
+
1562
+ const results = await inventoryRebalancer.rebalance([route]);
1563
+
1564
+ // Verify: Failure when all bridges fail
1565
+ expect(results).to.have.lengthOf(1);
1566
+ expect(results[0].success).to.be.false;
1567
+ expect(results[0].error).to.include('All inventory movements failed');
1568
+ });
1569
+ });
1570
+
1571
+ describe('Bridge Viability Check', () => {
1572
+ // Tests for the gas-aware planning approach that prevents "insufficient funds for gas * price + value" errors
1573
+ // by calculating max viable bridge amounts BEFORE creating bridge plans.
1574
+ // Uses calculateMaxViableBridgeAmount which:
1575
+ // 1. Gets a quote to determine gas costs
1576
+ // 2. Applies 20x multiplier on quoted gas (LiFi underestimates)
1577
+ // 3. Returns 0 if gas exceeds 10% of inventory (not economically viable)
1578
+
1579
+ it('filters out sources where gas cost exceeds 10% of inventory', async () => {
1580
+ // Setup: Native token bridge where gas cost is too high relative to balance
1581
+ // Scenario: Arbitrum has 0.00219 ETH, estimated gas (with 20x buffer) exceeds 10% threshold
1582
+ const arbitrumToken = {
1583
+ chainName: ARBITRUM_CHAIN,
1584
+ standard: TokenStandard.EvmHypNative, // Native token for gas check
1585
+ addressOrDenom: '0xArbitrumNative',
1586
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1587
+ };
1588
+ const solanaToken = {
1589
+ chainName: SOLANA_CHAIN,
1590
+ standard: TokenStandard.EvmHypNative,
1591
+ addressOrDenom: '0xSolanaNative',
1592
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1593
+ };
1594
+ warpCore.tokens = [arbitrumToken, solanaToken];
1595
+
1596
+ const amount = BigInt(1e18); // 1 ETH requested on destination
1597
+
1598
+ const route = createTestRoute({ amount });
1599
+ createTestIntent({ amount });
1600
+
1601
+ // Raw balance on source chain (ARBITRUM) - the limiting factor
1602
+ const rawBalance = BigInt('2194632084196208'); // ~0.00219 ETH
1603
+
1604
+ // No inventory on destination, low balance on source
1605
+ inventoryRebalancer.setInventoryBalances({
1606
+ [SOLANA_CHAIN]: 0n,
1607
+ [ARBITRUM_CHAIN]: rawBalance,
1608
+ });
1609
+
1610
+ // Mock quote with gas costs that exceed 10% threshold when multiplied by 20
1611
+ // rawBalance = 0.00219 ETH
1612
+ // 10% threshold = 0.000219 ETH
1613
+ // gasCosts = 0.00005 ETH, estimated = 0.001 ETH (20x multiplier)
1614
+ // 0.001 > 0.000219 → not viable
1615
+ bridge.quote.resolves(
1616
+ createMockBridgeQuote({
1617
+ fromAmount: rawBalance,
1618
+ toAmount: rawBalance - BigInt(1e14), // Some output
1619
+ gasCosts: BigInt('50000000000000'), // 0.00005 ETH, becomes 0.001 ETH with 20x
1620
+ feeCosts: 0n,
1621
+ }),
1622
+ );
1623
+
1624
+ const results = await inventoryRebalancer.rebalance([route]);
1625
+
1626
+ // Verify: Should fail because no sources pass viability check at planning time
1627
+ expect(results).to.have.lengthOf(1);
1628
+ expect(results[0].success).to.be.false;
1629
+ // New behavior: Error is "No viable bridge sources" (filtered at planning time)
1630
+ // rather than "Insufficient funds" (which was at execution time)
1631
+ expect(results[0].error).to.include('No viable bridge sources');
1632
+
1633
+ // Verify: Bridge.execute should NOT have been called (filtered during planning)
1634
+ expect(bridge.execute.called).to.be.false;
1635
+ });
1636
+
1637
+ it('proceeds with bridge when total cost is within available balance', async () => {
1638
+ // Setup: Native token bridge where we have enough balance
1639
+ const arbitrumToken = {
1640
+ chainName: ARBITRUM_CHAIN,
1641
+ standard: TokenStandard.EvmHypNative,
1642
+ addressOrDenom: '0xArbitrumNative',
1643
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1644
+ };
1645
+ const solanaToken = {
1646
+ chainName: SOLANA_CHAIN,
1647
+ standard: TokenStandard.EvmHypNative,
1648
+ addressOrDenom: '0xSolanaNative',
1649
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1650
+ };
1651
+ warpCore.tokens = [arbitrumToken, solanaToken];
1652
+
1653
+ const amount = BigInt(1e18); // 1 ETH requested
1654
+
1655
+ const route = createTestRoute({ amount });
1656
+ createTestIntent({ amount });
1657
+
1658
+ // Plenty of balance on source chain
1659
+ const rawBalance = BigInt(2e18); // 2 ETH - more than enough
1660
+
1661
+ inventoryRebalancer.setInventoryBalances({
1662
+ [SOLANA_CHAIN]: 0n,
1663
+ [ARBITRUM_CHAIN]: rawBalance,
1664
+ });
1665
+
1666
+ // Quote with reasonable costs well under the balance
1667
+ bridge.quote.resolves(
1668
+ createMockBridgeQuote({
1669
+ fromAmount: BigInt(1.05e18), // 1.05 ETH (with buffer)
1670
+ toAmount: BigInt(1e18),
1671
+ gasCosts: BigInt(1e15), // 0.001 ETH gas
1672
+ feeCosts: 0n,
1673
+ }),
1674
+ );
1675
+
1676
+ // Mock successful execution
1677
+ bridge.execute.resolves({
1678
+ txHash: '0xSuccessBridgeTxHash',
1679
+ fromChain: 42161,
1680
+ toChain: 1399811149,
1681
+ });
1682
+
1683
+ const results = await inventoryRebalancer.rebalance([route]);
1684
+
1685
+ // Verify: Should succeed
1686
+ expect(results).to.have.lengthOf(1);
1687
+ expect(results[0].success).to.be.true;
1688
+
1689
+ // Verify: Bridge.execute WAS called (not abandoned)
1690
+ expect(bridge.execute.calledOnce).to.be.true;
1691
+ });
1692
+
1693
+ it('viability check only applies to native tokens (not ERC20)', async () => {
1694
+ // Setup: ERC20 token bridge - gas is paid separately in ETH, not from token balance
1695
+ const arbitrumToken = {
1696
+ chainName: ARBITRUM_CHAIN,
1697
+ standard: TokenStandard.EvmHypCollateral, // ERC20, not native
1698
+ addressOrDenom: '0xArbitrumToken',
1699
+ collateralAddressOrDenom: '0xArbitrumCollateralERC20',
1700
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1701
+ };
1702
+ const solanaToken = {
1703
+ chainName: SOLANA_CHAIN,
1704
+ standard: TokenStandard.EvmHypCollateral,
1705
+ addressOrDenom: '0xSolanaToken',
1706
+ collateralAddressOrDenom: '0xSolanaCollateralERC20',
1707
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1708
+ };
1709
+ warpCore.tokens = [arbitrumToken, solanaToken];
1710
+
1711
+ const amount = BigInt(1e18); // 1 token
1712
+ const route = createTestRoute({ amount });
1713
+ createTestIntent({ amount });
1714
+
1715
+ // Small token balance (but gas is paid in ETH, so this shouldn't matter for viability)
1716
+ const tokenBalance = BigInt(1e18);
1717
+
1718
+ inventoryRebalancer.setInventoryBalances({
1719
+ [SOLANA_CHAIN]: 0n,
1720
+ [ARBITRUM_CHAIN]: tokenBalance,
1721
+ });
1722
+
1723
+ // Quote with high gas costs (but for ERC20, this shouldn't trigger viability check)
1724
+ bridge.quote.resolves(
1725
+ createMockBridgeQuote({
1726
+ fromAmount: BigInt(1.05e18),
1727
+ toAmount: BigInt(1e18),
1728
+ gasCosts: BigInt(1e18), // Very high gas (would fail viability if this were native)
1729
+ feeCosts: 0n,
1730
+ }),
1731
+ );
1732
+
1733
+ bridge.execute.resolves({
1734
+ txHash: '0xERC20BridgeTxHash',
1735
+ fromChain: 42161,
1736
+ toChain: 1399811149,
1737
+ });
1738
+
1739
+ const results = await inventoryRebalancer.rebalance([route]);
1740
+
1741
+ // Verify: Should succeed because ERC20 viability check doesn't include gasCosts
1742
+ expect(results).to.have.lengthOf(1);
1743
+ expect(results[0].success).to.be.true;
1744
+ expect(bridge.execute.calledOnce).to.be.true;
1745
+ });
1746
+
1747
+ it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
1748
+ // Setup: Native token with enough balance for viable bridge
1749
+ const arbitrumToken = {
1750
+ chainName: ARBITRUM_CHAIN,
1751
+ standard: TokenStandard.EvmHypNative,
1752
+ addressOrDenom: '0xArbitrumNative',
1753
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1754
+ };
1755
+ const solanaToken = {
1756
+ chainName: SOLANA_CHAIN,
1757
+ standard: TokenStandard.EvmHypNative,
1758
+ addressOrDenom: '0xSolanaNative',
1759
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1760
+ };
1761
+ warpCore.tokens = [arbitrumToken, solanaToken];
1762
+
1763
+ const amount = BigInt(0.5e18); // 0.5 ETH requested
1764
+
1765
+ const route = createTestRoute({ amount });
1766
+ createTestIntent({ amount });
1767
+
1768
+ // Large balance - should be viable
1769
+ const rawBalance = BigInt(1e18); // 1 ETH
1770
+
1771
+ inventoryRebalancer.setInventoryBalances({
1772
+ [SOLANA_CHAIN]: 0n,
1773
+ [ARBITRUM_CHAIN]: rawBalance,
1774
+ });
1775
+
1776
+ // gasCosts = 0.001 ETH, estimated = 0.02 ETH (20x multiplier)
1777
+ // maxViable = 1 ETH - 0.02 ETH = 0.98 ETH
1778
+ // 10% threshold = 0.1 ETH, estimatedGas (0.02) < threshold → viable
1779
+ const gasCosts = BigInt(0.001e18); // 0.001 ETH
1780
+ bridge.quote.resolves(
1781
+ createMockBridgeQuote({
1782
+ fromAmount: rawBalance,
1783
+ toAmount: rawBalance - BigInt(1e15),
1784
+ gasCosts,
1785
+ feeCosts: 0n,
1786
+ }),
1787
+ );
1788
+
1789
+ bridge.execute.resolves({
1790
+ txHash: '0xSuccessBridgeTxHash',
1791
+ fromChain: 42161,
1792
+ toChain: 1399811149,
1793
+ });
1794
+
1795
+ const results = await inventoryRebalancer.rebalance([route]);
1796
+
1797
+ // Verify: Should succeed - bridge is viable
1798
+ expect(results).to.have.lengthOf(1);
1799
+ expect(results[0].success).to.be.true;
1800
+ expect(bridge.execute.calledOnce).to.be.true;
1801
+
1802
+ // Verify: The quoted fromAmount should be the target (since maxViable > target)
1803
+ // For the execution quote (second quote call):
1804
+ // targetWithBuffer = (0.5 ETH) * 1.05 = 0.525 ETH (for non-inventory execution, costs are 0)
1805
+ const executionQuoteCall = bridge.quote
1806
+ .getCalls()
1807
+ .find(
1808
+ (call: any) =>
1809
+ call.args[0].fromAmount !== undefined &&
1810
+ call.args[0].fromAmount !== rawBalance,
1811
+ );
1812
+ // Since maxViable (0.98 ETH) > targetWithBuffer (0.525 ETH), we bridge exactly targetWithBuffer
1813
+ expect(executionQuoteCall).to.exist;
1814
+ });
1815
+
1816
+ it('handles quote failures gracefully by skipping the source chain', async () => {
1817
+ // Setup: Native token where quote fails
1818
+ const arbitrumToken = {
1819
+ chainName: ARBITRUM_CHAIN,
1820
+ standard: TokenStandard.EvmHypNative,
1821
+ addressOrDenom: '0xArbitrumNative',
1822
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1823
+ };
1824
+ const solanaToken = {
1825
+ chainName: SOLANA_CHAIN,
1826
+ standard: TokenStandard.EvmHypNative,
1827
+ addressOrDenom: '0xSolanaNative',
1828
+ getHypAdapter: Sinon.stub().returns(adapterStub),
1829
+ };
1830
+ warpCore.tokens = [arbitrumToken, solanaToken];
1831
+
1832
+ const amount = BigInt(1e18);
1833
+
1834
+ const route = createTestRoute({ amount });
1835
+ createTestIntent({ amount });
1836
+
1837
+ const rawBalance = BigInt(2e18); // 2 ETH
1838
+
1839
+ inventoryRebalancer.setInventoryBalances({
1840
+ [SOLANA_CHAIN]: 0n,
1841
+ [ARBITRUM_CHAIN]: rawBalance,
1842
+ });
1843
+
1844
+ // Quote fails for viability check
1845
+ bridge.quote.rejects(new Error('LiFi API timeout'));
1846
+
1847
+ const results = await inventoryRebalancer.rebalance([route]);
1848
+
1849
+ // Verify: Should fail because quote error means no viable sources
1850
+ expect(results).to.have.lengthOf(1);
1851
+ expect(results[0].success).to.be.false;
1852
+ expect(results[0].error).to.include('No viable bridge sources');
1853
+
1854
+ // Verify: Bridge.execute should NOT have been called
1855
+ expect(bridge.execute.called).to.be.false;
1856
+ });
1857
+ });
1858
+ });