@humanode-hyperlane/rebalancer 27.2.2-humanode.1 → 1029.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/dist/bridges/LiFiBridge.d.ts +3 -3
  2. package/dist/bridges/LiFiBridge.d.ts.map +1 -1
  3. package/dist/bridges/LiFiBridge.js +66 -55
  4. package/dist/bridges/LiFiBridge.js.map +1 -1
  5. package/dist/bridges/LiFiBridge.test.js +213 -0
  6. package/dist/bridges/LiFiBridge.test.js.map +1 -1
  7. package/dist/config/RebalancerConfig.d.ts +4 -3
  8. package/dist/config/RebalancerConfig.d.ts.map +1 -1
  9. package/dist/config/RebalancerConfig.js +7 -5
  10. package/dist/config/RebalancerConfig.js.map +1 -1
  11. package/dist/config/RebalancerConfig.test.js +278 -2
  12. package/dist/config/RebalancerConfig.test.js.map +1 -1
  13. package/dist/config/types.d.ts +707 -2664
  14. package/dist/config/types.d.ts.map +1 -1
  15. package/dist/config/types.js +62 -7
  16. package/dist/config/types.js.map +1 -1
  17. package/dist/core/InventoryRebalancer.d.ts +13 -19
  18. package/dist/core/InventoryRebalancer.d.ts.map +1 -1
  19. package/dist/core/InventoryRebalancer.js +404 -276
  20. package/dist/core/InventoryRebalancer.js.map +1 -1
  21. package/dist/core/InventoryRebalancer.test.js +814 -25
  22. package/dist/core/InventoryRebalancer.test.js.map +1 -1
  23. package/dist/core/Rebalancer.d.ts.map +1 -1
  24. package/dist/core/Rebalancer.js +12 -6
  25. package/dist/core/Rebalancer.js.map +1 -1
  26. package/dist/core/Rebalancer.test.js +51 -0
  27. package/dist/core/Rebalancer.test.js.map +1 -1
  28. package/dist/core/RebalancerOrchestrator.d.ts.map +1 -1
  29. package/dist/core/RebalancerOrchestrator.test.js +0 -1
  30. package/dist/core/RebalancerOrchestrator.test.js.map +1 -1
  31. package/dist/core/RebalancerService.d.ts.map +1 -1
  32. package/dist/core/RebalancerService.js +3 -2
  33. package/dist/core/RebalancerService.js.map +1 -1
  34. package/dist/core/RebalancerService.test.js +24 -0
  35. package/dist/core/RebalancerService.test.js.map +1 -1
  36. package/dist/e2e/fixtures/routes.d.ts +3 -3
  37. package/dist/e2e/fixtures/routes.d.ts.map +1 -1
  38. package/dist/e2e/harness/BaseLocalDeploymentManager.d.ts.map +1 -1
  39. package/dist/e2e/harness/BaseLocalDeploymentManager.js +3 -2
  40. package/dist/e2e/harness/BaseLocalDeploymentManager.js.map +1 -1
  41. package/dist/e2e/harness/Erc20InventoryLocalDeploymentManager.d.ts.map +1 -1
  42. package/dist/e2e/harness/Erc20InventoryLocalDeploymentManager.js.map +1 -1
  43. package/dist/e2e/harness/Erc20LocalDeploymentManager.d.ts.map +1 -1
  44. package/dist/e2e/harness/ForkIndexer.d.ts.map +1 -1
  45. package/dist/e2e/harness/ForkIndexer.js.map +1 -1
  46. package/dist/e2e/harness/LocalProvider.d.ts +3 -0
  47. package/dist/e2e/harness/LocalProvider.d.ts.map +1 -0
  48. package/dist/e2e/harness/LocalProvider.js +9 -0
  49. package/dist/e2e/harness/LocalProvider.js.map +1 -0
  50. package/dist/e2e/harness/LocalProvider.test.d.ts +2 -0
  51. package/dist/e2e/harness/LocalProvider.test.d.ts.map +1 -0
  52. package/dist/e2e/harness/LocalProvider.test.js +14 -0
  53. package/dist/e2e/harness/LocalProvider.test.js.map +1 -0
  54. package/dist/e2e/harness/MixedLocalDeploymentManager.d.ts.map +1 -1
  55. package/dist/e2e/harness/MixedLocalDeploymentManager.js.map +1 -1
  56. package/dist/e2e/harness/MockExplorerClient.d.ts.map +1 -1
  57. package/dist/e2e/harness/MockExplorerClient.js.map +1 -1
  58. package/dist/e2e/harness/MockExternalBridge.d.ts.map +1 -1
  59. package/dist/e2e/harness/MockExternalBridge.js.map +1 -1
  60. package/dist/e2e/harness/NativeLocalDeploymentManager.d.ts.map +1 -1
  61. package/dist/e2e/harness/NativeLocalDeploymentManager.js +2 -1
  62. package/dist/e2e/harness/NativeLocalDeploymentManager.js.map +1 -1
  63. package/dist/e2e/harness/SnapshotHelper.d.ts.map +1 -1
  64. package/dist/e2e/harness/SnapshotHelper.js +3 -5
  65. package/dist/e2e/harness/SnapshotHelper.js.map +1 -1
  66. package/dist/e2e/harness/TestHelpers.js +1 -2
  67. package/dist/e2e/harness/TestHelpers.js.map +1 -1
  68. package/dist/e2e/harness/TestRebalancer.d.ts +5 -1
  69. package/dist/e2e/harness/TestRebalancer.d.ts.map +1 -1
  70. package/dist/e2e/harness/TestRebalancer.js +14 -4
  71. package/dist/e2e/harness/TestRebalancer.js.map +1 -1
  72. package/dist/e2e/relayer-denylist.e2e-test.d.ts +2 -0
  73. package/dist/e2e/relayer-denylist.e2e-test.d.ts.map +1 -0
  74. package/dist/e2e/relayer-denylist.e2e-test.js +388 -0
  75. package/dist/e2e/relayer-denylist.e2e-test.js.map +1 -0
  76. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  77. package/dist/factories/RebalancerContextFactory.js +61 -31
  78. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  79. package/dist/factories/RebalancerContextFactory.test.js +336 -5
  80. package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
  81. package/dist/index.d.ts +2 -2
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +1 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/interfaces/IRebalancer.d.ts +4 -2
  86. package/dist/interfaces/IRebalancer.d.ts.map +1 -1
  87. package/dist/metrics/Metrics.d.ts.map +1 -1
  88. package/dist/metrics/Metrics.js +1 -1
  89. package/dist/metrics/Metrics.js.map +1 -1
  90. package/dist/metrics/PriceGetter.d.ts +1 -0
  91. package/dist/metrics/PriceGetter.d.ts.map +1 -1
  92. package/dist/metrics/PriceGetter.js +14 -4
  93. package/dist/metrics/PriceGetter.js.map +1 -1
  94. package/dist/metrics/PriceGetter.test.d.ts +2 -0
  95. package/dist/metrics/PriceGetter.test.d.ts.map +1 -0
  96. package/dist/metrics/PriceGetter.test.js +84 -0
  97. package/dist/metrics/PriceGetter.test.js.map +1 -0
  98. package/dist/metrics/scripts/metrics.d.ts +4 -4
  99. package/dist/monitor/Monitor.d.ts +1 -0
  100. package/dist/monitor/Monitor.d.ts.map +1 -1
  101. package/dist/monitor/Monitor.js +55 -46
  102. package/dist/monitor/Monitor.js.map +1 -1
  103. package/dist/monitor/Monitor.test.d.ts +2 -0
  104. package/dist/monitor/Monitor.test.d.ts.map +1 -0
  105. package/dist/monitor/Monitor.test.js +346 -0
  106. package/dist/monitor/Monitor.test.js.map +1 -0
  107. package/dist/service.d.ts +1 -1
  108. package/dist/service.d.ts.map +1 -1
  109. package/dist/service.js +9 -5
  110. package/dist/service.js.map +1 -1
  111. package/dist/strategy/BaseStrategy.d.ts.map +1 -1
  112. package/dist/strategy/BaseStrategy.js +13 -11
  113. package/dist/strategy/BaseStrategy.js.map +1 -1
  114. package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
  115. package/dist/strategy/CollateralDeficitStrategy.js +2 -2
  116. package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
  117. package/dist/strategy/CompositeStrategy.d.ts.map +1 -1
  118. package/dist/strategy/CompositeStrategy.js.map +1 -1
  119. package/dist/strategy/CompositeStrategy.test.js.map +1 -1
  120. package/dist/strategy/MinAmountStrategy.d.ts +1 -0
  121. package/dist/strategy/MinAmountStrategy.d.ts.map +1 -1
  122. package/dist/strategy/MinAmountStrategy.js +12 -8
  123. package/dist/strategy/MinAmountStrategy.js.map +1 -1
  124. package/dist/strategy/MinAmountStrategy.test.js +189 -2
  125. package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
  126. package/dist/strategy/StrategyFactory.d.ts.map +1 -1
  127. package/dist/strategy/StrategyFactory.js.map +1 -1
  128. package/dist/strategy/WeightedStrategy.d.ts.map +1 -1
  129. package/dist/test/helpers.d.ts +11 -3
  130. package/dist/test/helpers.d.ts.map +1 -1
  131. package/dist/test/helpers.js +9 -11
  132. package/dist/test/helpers.js.map +1 -1
  133. package/dist/test/lifiMocks.d.ts +6 -6
  134. package/dist/test/lifiMocks.d.ts.map +1 -1
  135. package/dist/test/lifiMocks.js +5 -2
  136. package/dist/test/lifiMocks.js.map +1 -1
  137. package/dist/test/pendingTransferMiddlewareMocks.d.ts +17 -0
  138. package/dist/test/pendingTransferMiddlewareMocks.d.ts.map +1 -0
  139. package/dist/test/pendingTransferMiddlewareMocks.js +43 -0
  140. package/dist/test/pendingTransferMiddlewareMocks.js.map +1 -0
  141. package/dist/tracking/ActionTracker.d.ts +4 -0
  142. package/dist/tracking/ActionTracker.d.ts.map +1 -1
  143. package/dist/tracking/ActionTracker.js +76 -9
  144. package/dist/tracking/ActionTracker.js.map +1 -1
  145. package/dist/tracking/ActionTracker.test.js +418 -1
  146. package/dist/tracking/ActionTracker.test.js.map +1 -1
  147. package/dist/tracking/IActionTracker.d.ts +4 -2
  148. package/dist/tracking/IActionTracker.d.ts.map +1 -1
  149. package/dist/tracking/InflightContextAdapter.d.ts.map +1 -1
  150. package/dist/tracking/InflightContextAdapter.js.map +1 -1
  151. package/dist/tracking/pendingTransferMiddlewares/BlacklistIsmMiddleware.d.ts +64 -0
  152. package/dist/tracking/pendingTransferMiddlewares/BlacklistIsmMiddleware.d.ts.map +1 -0
  153. package/dist/tracking/pendingTransferMiddlewares/BlacklistIsmMiddleware.js +474 -0
  154. package/dist/tracking/pendingTransferMiddlewares/BlacklistIsmMiddleware.js.map +1 -0
  155. package/dist/tracking/pendingTransferMiddlewares/BlacklistIsmMiddleware.test.d.ts +2 -0
  156. package/dist/tracking/pendingTransferMiddlewares/BlacklistIsmMiddleware.test.d.ts.map +1 -0
  157. package/dist/tracking/pendingTransferMiddlewares/BlacklistIsmMiddleware.test.js +1168 -0
  158. package/dist/tracking/pendingTransferMiddlewares/BlacklistIsmMiddleware.test.js.map +1 -0
  159. package/dist/tracking/pendingTransferMiddlewares/PendingTransferMiddleware.d.ts +61 -0
  160. package/dist/tracking/pendingTransferMiddlewares/PendingTransferMiddleware.d.ts.map +1 -0
  161. package/dist/tracking/pendingTransferMiddlewares/PendingTransferMiddleware.js +160 -0
  162. package/dist/tracking/pendingTransferMiddlewares/PendingTransferMiddleware.js.map +1 -0
  163. package/dist/tracking/pendingTransferMiddlewares/PendingTransferMiddleware.test.d.ts +2 -0
  164. package/dist/tracking/pendingTransferMiddlewares/PendingTransferMiddleware.test.d.ts.map +1 -0
  165. package/dist/tracking/pendingTransferMiddlewares/PendingTransferMiddleware.test.js +369 -0
  166. package/dist/tracking/pendingTransferMiddlewares/PendingTransferMiddleware.test.js.map +1 -0
  167. package/dist/tracking/pendingTransferMiddlewares/RelayerDenylistMiddleware.d.ts +60 -0
  168. package/dist/tracking/pendingTransferMiddlewares/RelayerDenylistMiddleware.d.ts.map +1 -0
  169. package/dist/tracking/pendingTransferMiddlewares/RelayerDenylistMiddleware.js +288 -0
  170. package/dist/tracking/pendingTransferMiddlewares/RelayerDenylistMiddleware.js.map +1 -0
  171. package/dist/tracking/pendingTransferMiddlewares/RelayerDenylistMiddleware.test.d.ts +2 -0
  172. package/dist/tracking/pendingTransferMiddlewares/RelayerDenylistMiddleware.test.d.ts.map +1 -0
  173. package/dist/tracking/pendingTransferMiddlewares/RelayerDenylistMiddleware.test.js +606 -0
  174. package/dist/tracking/pendingTransferMiddlewares/RelayerDenylistMiddleware.test.js.map +1 -0
  175. package/dist/tracking/pendingTransferMiddlewares/buildPendingTransferMiddlewares.d.ts +25 -0
  176. package/dist/tracking/pendingTransferMiddlewares/buildPendingTransferMiddlewares.d.ts.map +1 -0
  177. package/dist/tracking/pendingTransferMiddlewares/buildPendingTransferMiddlewares.js +41 -0
  178. package/dist/tracking/pendingTransferMiddlewares/buildPendingTransferMiddlewares.js.map +1 -0
  179. package/dist/tracking/pendingTransferMiddlewares/buildPendingTransferMiddlewares.test.d.ts +2 -0
  180. package/dist/tracking/pendingTransferMiddlewares/buildPendingTransferMiddlewares.test.d.ts.map +1 -0
  181. package/dist/tracking/pendingTransferMiddlewares/buildPendingTransferMiddlewares.test.js +141 -0
  182. package/dist/tracking/pendingTransferMiddlewares/buildPendingTransferMiddlewares.test.js.map +1 -0
  183. package/dist/tracking/pendingTransferMiddlewares/matchBody.d.ts +12 -0
  184. package/dist/tracking/pendingTransferMiddlewares/matchBody.d.ts.map +1 -0
  185. package/dist/tracking/pendingTransferMiddlewares/matchBody.js +29 -0
  186. package/dist/tracking/pendingTransferMiddlewares/matchBody.js.map +1 -0
  187. package/dist/tracking/pendingTransferMiddlewares/relayerDenylistRules.d.ts +59 -0
  188. package/dist/tracking/pendingTransferMiddlewares/relayerDenylistRules.d.ts.map +1 -0
  189. package/dist/tracking/pendingTransferMiddlewares/relayerDenylistRules.js +661 -0
  190. package/dist/tracking/pendingTransferMiddlewares/relayerDenylistRules.js.map +1 -0
  191. package/dist/tracking/pendingTransferMiddlewares/relayerDenylistRules.test.d.ts +2 -0
  192. package/dist/tracking/pendingTransferMiddlewares/relayerDenylistRules.test.d.ts.map +1 -0
  193. package/dist/tracking/pendingTransferMiddlewares/relayerDenylistRules.test.js +1093 -0
  194. package/dist/tracking/pendingTransferMiddlewares/relayerDenylistRules.test.js.map +1 -0
  195. package/dist/tracking/pendingTransferMiddlewares/types.d.ts +2 -0
  196. package/dist/tracking/pendingTransferMiddlewares/types.d.ts.map +1 -0
  197. package/dist/tracking/pendingTransferMiddlewares/types.js +4 -0
  198. package/dist/tracking/pendingTransferMiddlewares/types.js.map +1 -0
  199. package/dist/tracking/store/InMemoryStore.d.ts.map +1 -1
  200. package/dist/utils/ExplorerClient.d.ts +1 -0
  201. package/dist/utils/ExplorerClient.d.ts.map +1 -1
  202. package/dist/utils/ExplorerClient.js +8 -3
  203. package/dist/utils/ExplorerClient.js.map +1 -1
  204. package/dist/utils/ExplorerClient.test.js +25 -0
  205. package/dist/utils/ExplorerClient.test.js.map +1 -1
  206. package/dist/utils/balanceUtils.d.ts +7 -1
  207. package/dist/utils/balanceUtils.d.ts.map +1 -1
  208. package/dist/utils/balanceUtils.js +39 -1
  209. package/dist/utils/balanceUtils.js.map +1 -1
  210. package/dist/utils/balanceUtils.test.js +55 -1
  211. package/dist/utils/balanceUtils.test.js.map +1 -1
  212. package/dist/utils/blockTag.d.ts +5 -4
  213. package/dist/utils/blockTag.d.ts.map +1 -1
  214. package/dist/utils/blockTag.js +26 -10
  215. package/dist/utils/blockTag.js.map +1 -1
  216. package/dist/utils/blockTag.test.d.ts +2 -0
  217. package/dist/utils/blockTag.test.d.ts.map +1 -0
  218. package/dist/utils/blockTag.test.js +132 -0
  219. package/dist/utils/blockTag.test.js.map +1 -0
  220. package/dist/utils/bridgeUtils.js.map +1 -1
  221. package/dist/utils/errors.d.ts.map +1 -1
  222. package/dist/utils/gasEstimation.d.ts +2 -1
  223. package/dist/utils/gasEstimation.d.ts.map +1 -1
  224. package/dist/utils/gasEstimation.js +16 -13
  225. package/dist/utils/gasEstimation.js.map +1 -1
  226. package/dist/utils/gasEstimation.test.d.ts +2 -0
  227. package/dist/utils/gasEstimation.test.d.ts.map +1 -0
  228. package/dist/utils/gasEstimation.test.js +104 -0
  229. package/dist/utils/gasEstimation.test.js.map +1 -0
  230. package/dist/utils/tokenUtils.d.ts.map +1 -1
  231. package/dist/utils/tokenUtils.js +5 -2
  232. package/dist/utils/tokenUtils.js.map +1 -1
  233. package/package.json +23 -22
  234. package/src/bridges/LiFiBridge.test.ts +270 -0
  235. package/src/bridges/LiFiBridge.ts +90 -71
  236. package/src/config/RebalancerConfig.test.ts +315 -1
  237. package/src/config/RebalancerConfig.ts +6 -3
  238. package/src/config/types.ts +98 -8
  239. package/src/core/InventoryRebalancer.test.ts +1092 -38
  240. package/src/core/InventoryRebalancer.ts +584 -351
  241. package/src/core/Rebalancer.test.ts +84 -0
  242. package/src/core/Rebalancer.ts +22 -6
  243. package/src/core/RebalancerOrchestrator.test.ts +0 -1
  244. package/src/core/RebalancerService.test.ts +35 -0
  245. package/src/core/RebalancerService.ts +3 -2
  246. package/src/e2e/harness/BaseLocalDeploymentManager.ts +3 -1
  247. package/src/e2e/harness/LocalProvider.test.ts +16 -0
  248. package/src/e2e/harness/LocalProvider.ts +10 -0
  249. package/src/e2e/harness/NativeLocalDeploymentManager.ts +2 -1
  250. package/src/e2e/harness/SnapshotHelper.ts +2 -5
  251. package/src/e2e/harness/TestHelpers.ts +3 -3
  252. package/src/e2e/harness/TestRebalancer.ts +21 -1
  253. package/src/e2e/relayer-denylist.e2e-test.ts +558 -0
  254. package/src/factories/RebalancerContextFactory.test.ts +489 -8
  255. package/src/factories/RebalancerContextFactory.ts +86 -36
  256. package/src/index.ts +2 -0
  257. package/src/interfaces/IRebalancer.ts +4 -1
  258. package/src/metrics/Metrics.ts +1 -0
  259. package/src/metrics/PriceGetter.test.ts +113 -0
  260. package/src/metrics/PriceGetter.ts +18 -3
  261. package/src/monitor/Monitor.test.ts +408 -0
  262. package/src/monitor/Monitor.ts +123 -97
  263. package/src/service.ts +14 -8
  264. package/src/strategy/BaseStrategy.ts +18 -15
  265. package/src/strategy/CollateralDeficitStrategy.ts +4 -3
  266. package/src/strategy/MinAmountStrategy.test.ts +238 -2
  267. package/src/strategy/MinAmountStrategy.ts +29 -17
  268. package/src/test/helpers.ts +13 -12
  269. package/src/test/lifiMocks.ts +5 -2
  270. package/src/test/pendingTransferMiddlewareMocks.ts +65 -0
  271. package/src/tracking/ActionTracker.test.ts +635 -3
  272. package/src/tracking/ActionTracker.ts +92 -12
  273. package/src/tracking/IActionTracker.ts +4 -2
  274. package/src/tracking/pendingTransferMiddlewares/BlacklistIsmMiddleware.test.ts +1693 -0
  275. package/src/tracking/pendingTransferMiddlewares/BlacklistIsmMiddleware.ts +784 -0
  276. package/src/tracking/pendingTransferMiddlewares/PendingTransferMiddleware.test.ts +554 -0
  277. package/src/tracking/pendingTransferMiddlewares/PendingTransferMiddleware.ts +233 -0
  278. package/src/tracking/pendingTransferMiddlewares/RelayerDenylistMiddleware.test.ts +894 -0
  279. package/src/tracking/pendingTransferMiddlewares/RelayerDenylistMiddleware.ts +404 -0
  280. package/src/tracking/pendingTransferMiddlewares/buildPendingTransferMiddlewares.test.ts +205 -0
  281. package/src/tracking/pendingTransferMiddlewares/buildPendingTransferMiddlewares.ts +89 -0
  282. package/src/tracking/pendingTransferMiddlewares/fixtures/mainnet-denylist.json +3556 -0
  283. package/src/tracking/pendingTransferMiddlewares/matchBody.ts +43 -0
  284. package/src/tracking/pendingTransferMiddlewares/relayerDenylistRules.test.ts +1220 -0
  285. package/src/tracking/pendingTransferMiddlewares/relayerDenylistRules.ts +934 -0
  286. package/src/tracking/pendingTransferMiddlewares/types.ts +3 -0
  287. package/src/utils/ExplorerClient.test.ts +42 -0
  288. package/src/utils/ExplorerClient.ts +14 -3
  289. package/src/utils/balanceUtils.test.ts +87 -1
  290. package/src/utils/balanceUtils.ts +73 -2
  291. package/src/utils/blockTag.test.ts +173 -0
  292. package/src/utils/blockTag.ts +35 -15
  293. package/src/utils/gasEstimation.test.ts +192 -0
  294. package/src/utils/gasEstimation.ts +17 -11
  295. package/src/utils/tokenUtils.ts +5 -2
@@ -1,18 +1,20 @@
1
1
  import chai, { expect } from 'chai';
2
2
  import chaiAsPromised from 'chai-as-promised';
3
3
  import { Wallet } from 'ethers';
4
+ import type { Logger } from 'pino';
4
5
  import { pino } from 'pino';
5
6
  import Sinon, { type SinonStubbedInstance } from 'sinon';
6
7
 
7
8
  import {
8
9
  type ChainName,
10
+ HyperlaneCore,
9
11
  type MultiProvider,
10
12
  ProviderType,
11
13
  TokenStandard,
12
14
  WarpTxCategory,
13
15
  type WarpCore,
14
16
  } from '@humanode-hyperlane/sdk';
15
- import { ProtocolType } from '@humanode-hyperlane/utils';
17
+ import { assert, ProtocolType } from '@humanode-hyperlane/utils';
16
18
 
17
19
  import { ExternalBridgeType } from '../config/types.js';
18
20
  import type { IExternalBridge } from '../interfaces/IExternalBridge.js';
@@ -154,6 +156,9 @@ describe('InventoryRebalancer E2E', () => {
154
156
  }),
155
157
  },
156
158
  findToken: Sinon.stub().returns(null),
159
+ getMaxTransferAmount: Sinon.stub().callsFake(
160
+ async ({ balance }) => balance,
161
+ ),
157
162
  getTransferRemoteTxs: Sinon.stub().resolves([
158
163
  {
159
164
  category: 'transfer',
@@ -277,6 +282,21 @@ describe('InventoryRebalancer E2E', () => {
277
282
  return intent;
278
283
  }
279
284
 
285
+ function mockSuccessfulBridge(fromAmount: bigint, toAmount: bigint): void {
286
+ bridge.quote.resolves(
287
+ createMockBridgeQuote({
288
+ fromAmount,
289
+ toAmount,
290
+ toAmountMin: toAmount,
291
+ }),
292
+ );
293
+ bridge.execute.resolves({
294
+ txHash: '0xBridgeTxHash',
295
+ fromChain: 42161,
296
+ toChain: 1399811149,
297
+ });
298
+ }
299
+
280
300
  describe('Basic Inventory Rebalance (Sufficient Inventory)', () => {
281
301
  // NOTE: Strategy route is arbitrum (surplus) → solana (deficit)
282
302
  // But execution calls transferRemote FROM solana TO arbitrum (swapped direction)
@@ -396,11 +416,198 @@ describe('InventoryRebalancer E2E', () => {
396
416
  const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
397
417
  expect(actionParams.txHash).to.equal('0xSolanaTxHash');
398
418
  });
419
+
420
+ it('denormalizes inventory execution amounts but records canonical deposit amount', async () => {
421
+ const route = createTestRoute({ amount: 1_000_000n });
422
+ createTestIntent({ amount: 1_000_000n });
423
+ warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
424
+ numerator: 1n,
425
+ denominator: 1_000_000_000_000n,
426
+ };
427
+
428
+ inventoryRebalancer.setInventoryBalances({
429
+ [SOLANA_CHAIN]: 1_000_000_000_000_000_000n,
430
+ [ARBITRUM_CHAIN]: 0n,
431
+ });
432
+
433
+ const results = await inventoryRebalancer.rebalance([route]);
434
+
435
+ expect(results).to.have.lengthOf(1);
436
+ expect(results[0].success).to.be.true;
437
+
438
+ const txParams = warpCore.getTransferRemoteTxs.firstCall.args[0];
439
+ expect(txParams.originTokenAmount.amount).to.equal(
440
+ 1_000_000_000_000_000_000n,
441
+ );
442
+
443
+ const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
444
+ expect(actionParams.amount).to.equal(1_000_000n);
445
+ });
446
+ });
447
+
448
+ it('sendAndConfirmInventoryTx uses the EVM-like signer path for Tron txs', async () => {
449
+ const TRON_CHAIN = 'tron' as ChainName;
450
+ config.inventorySigners[ProtocolType.Tron] = {
451
+ address: INVENTORY_SIGNER,
452
+ key: TEST_PRIVATE_KEY,
453
+ };
454
+
455
+ const getChainMetadata = (chain: ChainName) => ({
456
+ name: chain,
457
+ protocol:
458
+ chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
459
+ blocks: { reorgPeriod: 2 },
460
+ rpcUrls: [{ http: 'http://127.0.0.1:9090/jsonrpc' }],
461
+ });
462
+ warpCore.multiProvider.getChainMetadata.callsFake(getChainMetadata);
463
+ multiProvider.getChainMetadata.callsFake(getChainMetadata);
464
+ warpCore.multiProvider.toMultiProvider =
465
+ Sinon.stub().returns(multiProvider);
466
+
467
+ const sendFn = (inventoryRebalancer as any).sendAndConfirmInventoryTx.bind(
468
+ inventoryRebalancer,
469
+ );
470
+ const result = await sendFn(TRON_CHAIN, {
471
+ category: WarpTxCategory.Transfer,
472
+ type: ProviderType.Tron,
473
+ transaction: {
474
+ to: '0xRouterAddress',
475
+ data: '0xTransferRemoteData',
476
+ value: 1000000n,
477
+ },
478
+ });
479
+
480
+ expect(result).to.deep.equal({ txHash: '0xTransferRemoteTxHash' });
481
+ expect(multiProvider.setSigner.calledOnce).to.be.true;
482
+ expect(
483
+ multiProvider.sendTransaction.calledOnceWithExactly(
484
+ TRON_CHAIN,
485
+ {
486
+ to: '0xRouterAddress',
487
+ data: '0xTransferRemoteData',
488
+ value: 1000000n,
489
+ },
490
+ { waitConfirmations: 2 },
491
+ ),
492
+ ).to.be.true;
493
+ });
494
+
495
+ it('buildSignerAccountConfig returns correct config for Tron protocol', () => {
496
+ config.inventorySigners[ProtocolType.Tron] = {
497
+ address: INVENTORY_SIGNER,
498
+ key: TEST_PRIVATE_KEY,
499
+ };
500
+
501
+ const buildFn = (inventoryRebalancer as any).buildSignerAccountConfig.bind(
502
+ inventoryRebalancer,
503
+ );
504
+ const result = buildFn(
505
+ ProtocolType.Tron,
506
+ TEST_PRIVATE_KEY,
507
+ 'tron' as ChainName,
508
+ );
509
+
510
+ expect(result.protocol).to.equal(ProtocolType.Tron);
511
+ expect(result.privateKey).to.equal(TEST_PRIVATE_KEY);
512
+ // Tron uses same 0x-prefixed hex key format as Ethereum
513
+ expect(result.privateKey.startsWith('0x')).to.be.true;
514
+ });
515
+
516
+ it('getTransactionReceipt returns EthersV5 type for Tron chain', async () => {
517
+ const TRON_CHAIN = 'tron' as ChainName;
518
+ const mockReceipt = {
519
+ transactionHash: '0xTronReceipt',
520
+ logs: [],
521
+ };
522
+
523
+ warpCore.multiProvider.getChainMetadata.callsFake((chain: ChainName) => ({
524
+ name: chain,
525
+ protocol:
526
+ chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
527
+ }));
528
+
529
+ warpCore.multiProvider.getEthersV5Provider.returns({
530
+ getTransactionReceipt: Sinon.stub().resolves(mockReceipt),
531
+ });
532
+
533
+ const getReceiptFn = (
534
+ inventoryRebalancer as any
535
+ ).getTransactionReceipt.bind(inventoryRebalancer);
536
+ const receipt = await getReceiptFn(TRON_CHAIN, '0xTronTxHash');
537
+
538
+ expect(receipt).to.not.be.undefined;
539
+ expect(receipt.type).to.equal(ProviderType.EthersV5);
540
+ expect(receipt.receipt).to.deep.equal(mockReceipt);
541
+ });
542
+
543
+ it('extractDispatchedMessageId returns Tron message ids via the EthersV5 path', async () => {
544
+ const TRON_CHAIN = 'tron' as ChainName;
545
+ const expectedMessageId =
546
+ '0x1111111111111111111111111111111111111111111111111111111111111111';
547
+ const mockReceipt = {
548
+ transactionHash: '0xTronTx',
549
+ logs: [{}],
550
+ };
551
+
552
+ warpCore.multiProvider.getChainMetadata.callsFake((chain: ChainName) => ({
553
+ name: chain,
554
+ protocol:
555
+ chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
556
+ }));
557
+
558
+ const getDispatchedMessagesStub = Sinon.stub(
559
+ HyperlaneCore,
560
+ 'getDispatchedMessages',
561
+ ).returns([{ id: expectedMessageId }] as any);
562
+ warpCore.multiProvider.getEthersV5Provider.returns({
563
+ getTransactionReceipt: Sinon.stub().resolves(mockReceipt),
564
+ });
565
+
566
+ const extractFn = (
567
+ inventoryRebalancer as any
568
+ ).extractDispatchedMessageId.bind(inventoryRebalancer);
569
+ const messageId = await extractFn(TRON_CHAIN, '0xTronTx');
570
+ expect(messageId).to.equal(expectedMessageId);
571
+ expect(getDispatchedMessagesStub.calledOnce).to.be.true;
572
+ expect(getDispatchedMessagesStub.firstCall.firstArg).to.equal(mockReceipt);
573
+ });
574
+
575
+ it('selects the Tron signer address from a mixed multi-protocol config', () => {
576
+ config.inventorySigners[ProtocolType.Tron] = {
577
+ address: INVENTORY_SIGNER,
578
+ key: TEST_PRIVATE_KEY,
579
+ };
580
+ config.inventorySigners[ProtocolType.Sealevel] = {
581
+ address: 'SoLANAAddReSs1234567890123456789012345678',
582
+ 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',
583
+ };
584
+
585
+ warpCore.multiProvider.getChainMetadata.callsFake((chain: ChainName) => ({
586
+ name: chain,
587
+ protocol:
588
+ chain === 'tron'
589
+ ? ProtocolType.Tron
590
+ : chain === SOLANA_CHAIN
591
+ ? ProtocolType.Sealevel
592
+ : ProtocolType.Ethereum,
593
+ }));
594
+
595
+ const getInventorySignerAddress = (
596
+ inventoryRebalancer as any
597
+ ).getInventorySignerAddress.bind(inventoryRebalancer);
598
+
599
+ expect(getInventorySignerAddress('tron' as ChainName)).to.equal(
600
+ INVENTORY_SIGNER,
601
+ );
602
+ expect(getInventorySignerAddress(SOLANA_CHAIN)).to.equal(
603
+ 'SoLANAAddReSs1234567890123456789012345678',
604
+ );
399
605
  });
400
606
 
401
607
  describe('Partial Fulfillment (Insufficient Inventory)', () => {
402
- // Partial transfers happen when maxTransferable >= minViableTransfer
403
- // For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so partial always viable
608
+ // Partial transfers happen when maxTransferable >= minViableTransfer.
609
+ // For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so any
610
+ // positive fee-aware maxTransferable remains viable.
404
611
  const PARTIAL_AMOUNT = BigInt(5e15); // 0.005 ETH - above threshold
405
612
  const FULL_AMOUNT = BigInt(1e16); // 0.01 ETH
406
613
 
@@ -433,6 +640,65 @@ describe('InventoryRebalancer E2E', () => {
433
640
  expect(actionParams.amount).to.equal(PARTIAL_AMOUNT);
434
641
  });
435
642
 
643
+ it('uses fee-aware maxTransferable for non-native token fees', async () => {
644
+ const requestedAmount = 19998000000n;
645
+ const availableInventory = 102466n;
646
+ const safeTransferAmount = 102400n;
647
+
648
+ warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
649
+
650
+ const route = createTestRoute({ amount: requestedAmount });
651
+ createTestIntent({ amount: requestedAmount });
652
+
653
+ inventoryRebalancer.setInventoryBalances({
654
+ [SOLANA_CHAIN]: availableInventory,
655
+ [ARBITRUM_CHAIN]: 0n,
656
+ });
657
+
658
+ const results = await inventoryRebalancer.rebalance([route]);
659
+
660
+ expect(results).to.have.lengthOf(1);
661
+ expect(results[0].success).to.be.true;
662
+ expect(warpCore.getMaxTransferAmount.calledOnce).to.be.true;
663
+
664
+ const maxTransferArgs = warpCore.getMaxTransferAmount.firstCall.args[0];
665
+ expect(maxTransferArgs.balance.amount).to.equal(availableInventory);
666
+ expect(maxTransferArgs.destination).to.equal(ARBITRUM_CHAIN);
667
+
668
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
669
+ expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
670
+ expect(txParams).to.not.have.property('interchainFee');
671
+ expect(txParams).to.not.have.property('tokenFeeQuote');
672
+
673
+ const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
674
+ expect(actionParams.amount).to.equal(safeTransferAmount);
675
+ });
676
+
677
+ it('downgrades full non-native transfers to partial when fee headroom is missing', async () => {
678
+ const requestedAmount = 102466n;
679
+ const safeTransferAmount = 102400n;
680
+
681
+ warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
682
+
683
+ const route = createTestRoute({ amount: requestedAmount });
684
+ createTestIntent({ amount: requestedAmount });
685
+
686
+ inventoryRebalancer.setInventoryBalances({
687
+ [SOLANA_CHAIN]: requestedAmount,
688
+ [ARBITRUM_CHAIN]: 0n,
689
+ });
690
+
691
+ const results = await inventoryRebalancer.rebalance([route]);
692
+
693
+ expect(results).to.have.lengthOf(1);
694
+ expect(results[0].success).to.be.true;
695
+ expect(bridge.execute.called).to.be.false;
696
+
697
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
698
+ expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
699
+ expect(txParams.originTokenAmount.amount).to.not.equal(requestedAmount);
700
+ });
701
+
436
702
  it('intent remains in_progress after partial fulfillment', async () => {
437
703
  const route = createTestRoute({ amount: FULL_AMOUNT });
438
704
  createTestIntent({ amount: FULL_AMOUNT });
@@ -450,6 +716,52 @@ describe('InventoryRebalancer E2E', () => {
450
716
  });
451
717
  });
452
718
 
719
+ describe('Fee-Aware Probe Fallback', () => {
720
+ it('bridges when non-native destination inventory is zero', async () => {
721
+ const requestedAmount = 10000000000n;
722
+ const bufferedBridgeAmount = (requestedAmount * 105n) / 100n;
723
+ const route = createTestRoute({ amount: requestedAmount });
724
+ createTestIntent({ amount: requestedAmount });
725
+
726
+ inventoryRebalancer.setInventoryBalances({
727
+ [SOLANA_CHAIN]: 0n,
728
+ [ARBITRUM_CHAIN]: 20000000000n,
729
+ });
730
+ mockSuccessfulBridge(bufferedBridgeAmount, bufferedBridgeAmount);
731
+
732
+ const results = await inventoryRebalancer.rebalance([route]);
733
+
734
+ expect(results).to.have.lengthOf(1);
735
+ expect(results[0].success).to.be.true;
736
+ expect(warpCore.getMaxTransferAmount.called).to.be.false;
737
+ expect(warpCore.getTransferRemoteTxs.called).to.be.false;
738
+ expect(bridge.execute.calledOnce).to.be.true;
739
+
740
+ const actionParams =
741
+ actionTracker.createRebalanceAction.firstCall.args[0];
742
+ expect(actionParams.type).to.equal('inventory_movement');
743
+ });
744
+
745
+ it('returns failure for unrelated fee-aware probe errors', async () => {
746
+ const route = createTestRoute();
747
+ createTestIntent();
748
+
749
+ inventoryRebalancer.setInventoryBalances({
750
+ [SOLANA_CHAIN]: 1n,
751
+ [ARBITRUM_CHAIN]: 20000000000n,
752
+ });
753
+ warpCore.getMaxTransferAmount.rejects(new Error('RPC down'));
754
+
755
+ const results = await inventoryRebalancer.rebalance([route]);
756
+
757
+ expect(results).to.have.lengthOf(1);
758
+ expect(results[0].success).to.be.false;
759
+ expect(results[0].error).to.include('RPC down');
760
+ expect(bridge.execute.called).to.be.false;
761
+ expect(actionTracker.createRebalanceAction.called).to.be.false;
762
+ });
763
+ });
764
+
453
765
  describe('No Inventory Available', () => {
454
766
  it('returns failure when no inventory on destination chain and no other source available', async () => {
455
767
  const route = createTestRoute();
@@ -626,6 +938,51 @@ describe('InventoryRebalancer E2E', () => {
626
938
  // Verify: No new intent was created (existing was continued)
627
939
  expect(actionTracker.createRebalanceIntent.called).to.be.false;
628
940
  });
941
+
942
+ it('continues existing intent by bridging after recoverable fee-aware probe failure', async () => {
943
+ const existingIntent = createTestIntent({
944
+ id: 'existing-intent',
945
+ status: 'in_progress',
946
+ amount: 10000000000n,
947
+ externalBridge: ExternalBridgeType.LiFi,
948
+ });
949
+ const recoverableProbeError = new Error('Fee probe failed') as Error & {
950
+ cause?: unknown;
951
+ };
952
+ recoverableProbeError.cause = {
953
+ code: 'UNPREDICTABLE_GAS_LIMIT',
954
+ message: 'execution reverted: ERC20: transfer amount exceeds balance',
955
+ };
956
+
957
+ actionTracker.getPartiallyFulfilledInventoryIntents.resolves([
958
+ {
959
+ intent: existingIntent,
960
+ completedAmount: 0n,
961
+ remaining: 10000000000n,
962
+ hasInflightDeposit: false,
963
+ },
964
+ ]);
965
+
966
+ inventoryRebalancer.setInventoryBalances({
967
+ [SOLANA_CHAIN]: 1n,
968
+ [ARBITRUM_CHAIN]: 20000000000n,
969
+ });
970
+ warpCore.getMaxTransferAmount.rejects(recoverableProbeError);
971
+ mockSuccessfulBridge(10500000000n, 10500000000n);
972
+
973
+ const results = await inventoryRebalancer.rebalance([
974
+ createTestRoute({ amount: 5000000000n }),
975
+ ]);
976
+
977
+ expect(results).to.have.lengthOf(1);
978
+ expect(results[0].success).to.be.true;
979
+ expect(bridge.execute.calledOnce).to.be.true;
980
+ expect(actionTracker.createRebalanceIntent.called).to.be.false;
981
+
982
+ const actionParams =
983
+ actionTracker.createRebalanceAction.firstCall.args[0];
984
+ expect(actionParams.type).to.equal('inventory_movement');
985
+ });
629
986
  });
630
987
 
631
988
  describe('Error Handling', () => {
@@ -1457,6 +1814,83 @@ describe('InventoryRebalancer E2E', () => {
1457
1814
  expect(bridge.execute.callCount).to.equal(2);
1458
1815
  });
1459
1816
 
1817
+ it('does not inflate each split bridge plan to minViableTransfer', async () => {
1818
+ const amount = 7_000_000_000_000_000n;
1819
+ const perChainInventory = 6_000_000_000_000_000n;
1820
+ const reservedGas = 1_000_000_000n;
1821
+
1822
+ for (const token of warpCore.tokens) {
1823
+ if (
1824
+ token.chainName === ARBITRUM_CHAIN ||
1825
+ token.chainName === SOLANA_CHAIN ||
1826
+ token.chainName === BASE_CHAIN
1827
+ ) {
1828
+ token.standard = TokenStandard.EvmHypNative;
1829
+ }
1830
+ }
1831
+
1832
+ const route = createTestRoute({ amount });
1833
+ createTestIntent({ amount });
1834
+
1835
+ inventoryRebalancer.setInventoryBalances({
1836
+ [SOLANA_CHAIN]: 0n,
1837
+ [ARBITRUM_CHAIN]: perChainInventory,
1838
+ [BASE_CHAIN]: perChainInventory,
1839
+ });
1840
+
1841
+ bridge.quote.callsFake(async (params: any) =>
1842
+ createMockBridgeQuote({
1843
+ fromAmount: params.fromAmount ?? params.toAmount,
1844
+ toAmount: params.toAmount ?? params.fromAmount,
1845
+ toAmountMin: params.toAmount ?? params.fromAmount,
1846
+ requestParams:
1847
+ params.toAmount !== undefined
1848
+ ? { ...params, toAmount: params.toAmount }
1849
+ : { ...params, fromAmount: params.fromAmount },
1850
+ }),
1851
+ );
1852
+ bridge.execute.resolves({
1853
+ txHash: '0xBridgeTxHash',
1854
+ fromChain: 42161,
1855
+ toChain: 1399811149,
1856
+ });
1857
+
1858
+ const results = await inventoryRebalancer.rebalance([route]);
1859
+
1860
+ expect(results).to.have.lengthOf(1);
1861
+ expect(results[0].success).to.be.true;
1862
+ expect(bridge.execute.callCount).to.equal(2);
1863
+
1864
+ const maxPerSourceOutput = perChainInventory - reservedGas;
1865
+ const executionQuoteRequests = bridge.quote
1866
+ .getCalls()
1867
+ .slice(-2)
1868
+ .map((call) => call.args[0]);
1869
+ expect(executionQuoteRequests).to.have.lengthOf(2);
1870
+
1871
+ const forwardQuoteRequests = executionQuoteRequests.filter(
1872
+ (params) => params.fromAmount !== undefined,
1873
+ );
1874
+ const reverseQuoteRequests = executionQuoteRequests.filter(
1875
+ (params) => params.toAmount !== undefined,
1876
+ );
1877
+ const forwardAmounts = forwardQuoteRequests.map(
1878
+ (params) => params.fromAmount as bigint,
1879
+ );
1880
+
1881
+ expect(
1882
+ forwardQuoteRequests.length + reverseQuoteRequests.length,
1883
+ ).to.equal(2);
1884
+ expect(forwardQuoteRequests.length).to.be.greaterThan(0);
1885
+ expect(forwardAmounts.every((amount) => amount <= maxPerSourceOutput)).to
1886
+ .be.true;
1887
+ expect(
1888
+ reverseQuoteRequests.every(
1889
+ (params) => (params.toAmount as bigint) <= maxPerSourceOutput,
1890
+ ),
1891
+ ).to.be.true;
1892
+ });
1893
+
1460
1894
  it('applies 5% buffer to total bridge amount', async () => {
1461
1895
  // Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
1462
1896
  const amount = BigInt(1e18); // 1 ETH
@@ -1471,14 +1905,19 @@ describe('InventoryRebalancer E2E', () => {
1471
1905
  [ARBITRUM_CHAIN]: availableInventory,
1472
1906
  });
1473
1907
 
1474
- // Capture the quote amount from executeInventoryMovement
1475
- // (calculateMaxViableBridgeAmount doesn't quote for ERC20 tokens)
1476
- let quotedFromAmount: bigint | undefined;
1908
+ let quotedTargetOutput: bigint | undefined;
1477
1909
  bridge.quote.callsFake(async (params: any) => {
1478
- quotedFromAmount = params.fromAmount;
1910
+ if (params.toAmount !== undefined) {
1911
+ quotedTargetOutput = params.toAmount;
1912
+ }
1479
1913
  return createMockBridgeQuote({
1480
1914
  fromAmount: params.fromAmount ?? params.toAmount,
1481
- toAmount: params.fromAmount ?? params.toAmount,
1915
+ toAmount: params.toAmount ?? params.fromAmount,
1916
+ toAmountMin: params.toAmount ?? params.fromAmount,
1917
+ requestParams:
1918
+ params.toAmount !== undefined
1919
+ ? { ...params, toAmount: params.toAmount }
1920
+ : { ...params, fromAmount: params.fromAmount },
1482
1921
  });
1483
1922
  });
1484
1923
  bridge.execute.resolves({
@@ -1490,10 +1929,465 @@ describe('InventoryRebalancer E2E', () => {
1490
1929
  await inventoryRebalancer.rebalance([route]);
1491
1930
 
1492
1931
  // 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
1932
  const expectedWithBuffer = (amount * 105n) / 100n;
1496
- expect(quotedFromAmount).to.equal(expectedWithBuffer);
1933
+ expect(quotedTargetOutput).to.equal(expectedWithBuffer);
1934
+ });
1935
+
1936
+ it('bridges only the mixed-decimal shortfall after dust partial skip', async () => {
1937
+ const canonicalAmount = 1n;
1938
+ const destinationDust = 1_000_000_000_000n - 1n;
1939
+ const sourceInventory = 1_000_000_000_000n;
1940
+ warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
1941
+ numerator: 1n,
1942
+ denominator: 1_000_000_000_000n,
1943
+ };
1944
+
1945
+ const route = createTestRoute({ amount: canonicalAmount });
1946
+ createTestIntent({ amount: canonicalAmount });
1947
+
1948
+ inventoryRebalancer.setInventoryBalances({
1949
+ [SOLANA_CHAIN]: destinationDust,
1950
+ [ARBITRUM_CHAIN]: sourceInventory,
1951
+ });
1952
+
1953
+ let reverseQuoteTargetOutput: bigint | undefined;
1954
+ bridge.quote.callsFake(async (params: any) => {
1955
+ if (params.toAmount !== undefined) {
1956
+ reverseQuoteTargetOutput = params.toAmount;
1957
+ }
1958
+ return createMockBridgeQuote({
1959
+ fromAmount: params.fromAmount ?? params.toAmount,
1960
+ toAmount: params.toAmount ?? params.fromAmount,
1961
+ toAmountMin: params.toAmount ?? params.fromAmount,
1962
+ requestParams:
1963
+ params.toAmount !== undefined
1964
+ ? { ...params, toAmount: params.toAmount }
1965
+ : { ...params, fromAmount: params.fromAmount },
1966
+ });
1967
+ });
1968
+ bridge.execute.resolves({
1969
+ txHash: '0xBridgeTxHash',
1970
+ fromChain: 42161,
1971
+ toChain: 1399811149,
1972
+ });
1973
+
1974
+ const results = await inventoryRebalancer.rebalance([route]);
1975
+
1976
+ expect(results).to.have.lengthOf(1);
1977
+ expect(results[0].success).to.be.true;
1978
+ expect(warpCore.getTransferRemoteTxs.called).to.be.false;
1979
+ expect(bridge.execute.calledOnce).to.be.true;
1980
+ expect(reverseQuoteTargetOutput).to.equal(1n);
1981
+ });
1982
+
1983
+ it('plans LiFi target output in destination-local units for mixed-decimal routes', async () => {
1984
+ const canonicalAmount = 1_000_000n;
1985
+ const availableInventory = BigInt(2e18);
1986
+ warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
1987
+ numerator: 1n,
1988
+ denominator: 1_000_000_000_000n,
1989
+ };
1990
+
1991
+ const route = createTestRoute({ amount: canonicalAmount });
1992
+ createTestIntent({ amount: canonicalAmount });
1993
+
1994
+ inventoryRebalancer.setInventoryBalances({
1995
+ [SOLANA_CHAIN]: 0n,
1996
+ [ARBITRUM_CHAIN]: availableInventory,
1997
+ });
1998
+
1999
+ let quotedTargetOutput: bigint | undefined;
2000
+ bridge.quote.callsFake(async (params: any) => {
2001
+ if (params.toAmount !== undefined) {
2002
+ quotedTargetOutput = params.toAmount;
2003
+ }
2004
+ return createMockBridgeQuote({
2005
+ fromAmount: params.fromAmount ?? params.toAmount,
2006
+ toAmount: params.toAmount ?? params.fromAmount,
2007
+ toAmountMin: params.toAmount ?? params.fromAmount,
2008
+ requestParams:
2009
+ params.toAmount !== undefined
2010
+ ? { ...params, toAmount: params.toAmount }
2011
+ : { ...params, fromAmount: params.fromAmount },
2012
+ });
2013
+ });
2014
+ bridge.execute.resolves({
2015
+ txHash: '0xBridgeTxHash',
2016
+ fromChain: 42161,
2017
+ toChain: 1399811149,
2018
+ });
2019
+
2020
+ await inventoryRebalancer.rebalance([route]);
2021
+
2022
+ expect(quotedTargetOutput).to.equal(1_050_000_000_000_000_000n);
2023
+ });
2024
+
2025
+ it('retries forward execution when reverse quote exceeds planned source capacity', async () => {
2026
+ const amount = BigInt(1e18);
2027
+ const sourceInventory = BigInt(2e18);
2028
+ const targetWithBuffer = (amount * 105n) / 100n;
2029
+
2030
+ const route = createTestRoute({ amount });
2031
+ createTestIntent({ amount });
2032
+
2033
+ inventoryRebalancer.setInventoryBalances({
2034
+ [SOLANA_CHAIN]: 0n,
2035
+ [ARBITRUM_CHAIN]: sourceInventory,
2036
+ });
2037
+
2038
+ bridge.quote
2039
+ .onFirstCall()
2040
+ .resolves(
2041
+ createMockBridgeQuote({
2042
+ fromAmount: sourceInventory,
2043
+ toAmount: sourceInventory,
2044
+ toAmountMin: sourceInventory,
2045
+ requestParams: {
2046
+ fromChain: 42161,
2047
+ toChain: 1399811149,
2048
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2049
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2050
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2051
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2052
+ fromAmount: sourceInventory,
2053
+ },
2054
+ }),
2055
+ )
2056
+ .onSecondCall()
2057
+ .resolves(
2058
+ createMockBridgeQuote({
2059
+ fromAmount: sourceInventory + 1n,
2060
+ toAmount: targetWithBuffer,
2061
+ toAmountMin: targetWithBuffer,
2062
+ requestParams: {
2063
+ fromChain: 42161,
2064
+ toChain: 1399811149,
2065
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2066
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2067
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2068
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2069
+ toAmount: targetWithBuffer,
2070
+ },
2071
+ }),
2072
+ )
2073
+ .onThirdCall()
2074
+ .resolves(
2075
+ createMockBridgeQuote({
2076
+ fromAmount: sourceInventory,
2077
+ toAmount: targetWithBuffer - 1n,
2078
+ toAmountMin: targetWithBuffer - 1n,
2079
+ requestParams: {
2080
+ fromChain: 42161,
2081
+ toChain: 1399811149,
2082
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2083
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2084
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2085
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2086
+ fromAmount: sourceInventory,
2087
+ },
2088
+ }),
2089
+ );
2090
+
2091
+ bridge.execute.resolves({
2092
+ txHash: '0xBridgeTxHash',
2093
+ fromChain: 42161,
2094
+ toChain: 1399811149,
2095
+ });
2096
+
2097
+ const results = await inventoryRebalancer.rebalance([route]);
2098
+
2099
+ expect(results).to.have.lengthOf(1);
2100
+ expect(results[0].success).to.be.true;
2101
+ expect(bridge.execute.calledOnce).to.be.true;
2102
+ expect(bridge.quote.callCount).to.equal(3);
2103
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(
2104
+ targetWithBuffer,
2105
+ );
2106
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(
2107
+ sourceInventory,
2108
+ );
2109
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
2110
+ });
2111
+
2112
+ it('fails when forward retry still exceeds planned source capacity', async () => {
2113
+ const amount = BigInt(1e18);
2114
+ const sourceInventory = BigInt(2e18);
2115
+ const targetWithBuffer = (amount * 105n) / 100n;
2116
+
2117
+ const route = createTestRoute({ amount });
2118
+ createTestIntent({ amount });
2119
+
2120
+ inventoryRebalancer.setInventoryBalances({
2121
+ [SOLANA_CHAIN]: 0n,
2122
+ [ARBITRUM_CHAIN]: sourceInventory,
2123
+ });
2124
+
2125
+ bridge.quote
2126
+ .onFirstCall()
2127
+ .resolves(
2128
+ createMockBridgeQuote({
2129
+ fromAmount: sourceInventory,
2130
+ toAmount: sourceInventory,
2131
+ toAmountMin: sourceInventory,
2132
+ requestParams: {
2133
+ fromChain: 42161,
2134
+ toChain: 1399811149,
2135
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2136
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2137
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2138
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2139
+ fromAmount: sourceInventory,
2140
+ },
2141
+ }),
2142
+ )
2143
+ .onSecondCall()
2144
+ .resolves(
2145
+ createMockBridgeQuote({
2146
+ fromAmount: sourceInventory + 1n,
2147
+ toAmount: targetWithBuffer,
2148
+ toAmountMin: targetWithBuffer,
2149
+ requestParams: {
2150
+ fromChain: 42161,
2151
+ toChain: 1399811149,
2152
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2153
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2154
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2155
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2156
+ toAmount: targetWithBuffer,
2157
+ },
2158
+ }),
2159
+ )
2160
+ .onThirdCall()
2161
+ .resolves(
2162
+ createMockBridgeQuote({
2163
+ fromAmount: sourceInventory + 1n,
2164
+ toAmount: targetWithBuffer - 1n,
2165
+ toAmountMin: targetWithBuffer - 1n,
2166
+ requestParams: {
2167
+ fromChain: 42161,
2168
+ toChain: 1399811149,
2169
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2170
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2171
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2172
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2173
+ fromAmount: sourceInventory,
2174
+ },
2175
+ }),
2176
+ );
2177
+
2178
+ const results = await inventoryRebalancer.rebalance([route]);
2179
+
2180
+ expect(results).to.have.lengthOf(1);
2181
+ expect(results[0].success).to.be.false;
2182
+ expect(results[0].error).to.include('All inventory movements failed');
2183
+ expect(results[0].error).to.include('exceeded planned source capacity');
2184
+ expect(bridge.quote.callCount).to.equal(3);
2185
+ expect(bridge.execute.called).to.be.false;
2186
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(
2187
+ targetWithBuffer,
2188
+ );
2189
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(
2190
+ sourceInventory,
2191
+ );
2192
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
2193
+ });
2194
+
2195
+ it('fails gracefully when execute-time bridge quote rejects', async () => {
2196
+ const amount = BigInt(1e18);
2197
+ const sourceInventory = BigInt(2e18);
2198
+
2199
+ const route = createTestRoute({ amount });
2200
+ createTestIntent({ amount });
2201
+
2202
+ inventoryRebalancer.setInventoryBalances({
2203
+ [SOLANA_CHAIN]: 0n,
2204
+ [ARBITRUM_CHAIN]: sourceInventory,
2205
+ });
2206
+
2207
+ bridge.quote
2208
+ .onFirstCall()
2209
+ .resolves(
2210
+ createMockBridgeQuote({
2211
+ fromAmount: sourceInventory,
2212
+ toAmount: sourceInventory,
2213
+ toAmountMin: sourceInventory,
2214
+ requestParams: {
2215
+ fromChain: 42161,
2216
+ toChain: 1399811149,
2217
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2218
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2219
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2220
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2221
+ fromAmount: sourceInventory,
2222
+ },
2223
+ }),
2224
+ )
2225
+ .onSecondCall()
2226
+ .rejects(new Error('LiFi API timeout'));
2227
+
2228
+ const results = await inventoryRebalancer.rebalance([route]);
2229
+
2230
+ expect(results).to.have.lengthOf(1);
2231
+ expect(results[0].success).to.be.false;
2232
+ expect(results[0].error).to.include('All inventory movements failed');
2233
+ expect(results[0].error).to.include('LiFi API timeout');
2234
+ expect(bridge.quote.callCount).to.equal(2);
2235
+ expect(bridge.execute.called).to.be.false;
2236
+ });
2237
+
2238
+ it('preserves non-Error rejection reasons from parallel bridge execution', async () => {
2239
+ const amount = BigInt(1e18);
2240
+ const sourceInventory = BigInt(2e18);
2241
+
2242
+ const route = createTestRoute({ amount });
2243
+ createTestIntent({ amount });
2244
+
2245
+ inventoryRebalancer.setInventoryBalances({
2246
+ [SOLANA_CHAIN]: 0n,
2247
+ [ARBITRUM_CHAIN]: sourceInventory,
2248
+ });
2249
+
2250
+ bridge.quote.onFirstCall().resolves(
2251
+ createMockBridgeQuote({
2252
+ fromAmount: sourceInventory,
2253
+ toAmount: sourceInventory,
2254
+ toAmountMin: sourceInventory,
2255
+ requestParams: {
2256
+ fromChain: 42161,
2257
+ toChain: 1399811149,
2258
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2259
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2260
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2261
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2262
+ fromAmount: sourceInventory,
2263
+ },
2264
+ }),
2265
+ );
2266
+
2267
+ // Simulate a foreign promise rejecting with a non-Error reason at runtime.
2268
+ const rejectionReason = 'bridge exploded' as unknown as Error;
2269
+
2270
+ const executeInventoryMovementStub = Sinon.stub(
2271
+ inventoryRebalancer as unknown as {
2272
+ executeInventoryMovement: () => Promise<unknown>;
2273
+ },
2274
+ 'executeInventoryMovement',
2275
+ ).callsFake(() =>
2276
+ Promise.resolve().then(() => {
2277
+ throw rejectionReason;
2278
+ }),
2279
+ );
2280
+
2281
+ const results = await inventoryRebalancer.rebalance([route]);
2282
+
2283
+ expect(results).to.have.lengthOf(1);
2284
+ expect(results[0].success).to.be.false;
2285
+ expect(results[0].error).to.include('All inventory movements failed');
2286
+ expect(results[0].error).to.include('arbitrum: bridge exploded');
2287
+ expect(executeInventoryMovementStub.calledOnce).to.be.true;
2288
+ expect(bridge.execute.called).to.be.false;
2289
+
2290
+ executeInventoryMovementStub.restore();
2291
+ });
2292
+
2293
+ it('logs actual successful bridge output floors instead of planned output totals', async () => {
2294
+ const amount = BigInt(1e18);
2295
+ const perChainInventory = BigInt(0.6e18);
2296
+ const logger = {
2297
+ info: Sinon.stub(),
2298
+ debug: Sinon.stub(),
2299
+ warn: Sinon.stub(),
2300
+ error: Sinon.stub(),
2301
+ };
2302
+
2303
+ const localRebalancer = new InventoryRebalancer(
2304
+ config,
2305
+ actionTracker as unknown as IActionTracker,
2306
+ { lifi: bridge as unknown as IExternalBridge },
2307
+ warpCore as unknown as WarpCore,
2308
+ multiProvider as unknown as MultiProvider,
2309
+ logger as unknown as Logger,
2310
+ );
2311
+
2312
+ const route = createTestRoute({ amount });
2313
+ createTestIntent({ amount });
2314
+
2315
+ localRebalancer.setInventoryBalances({
2316
+ [SOLANA_CHAIN]: 0n,
2317
+ [ARBITRUM_CHAIN]: perChainInventory,
2318
+ [BASE_CHAIN]: perChainInventory,
2319
+ });
2320
+
2321
+ bridge.quote
2322
+ .onCall(0)
2323
+ .resolves(
2324
+ createMockBridgeQuote({
2325
+ fromAmount: perChainInventory,
2326
+ toAmount: BigInt(0.525e18),
2327
+ toAmountMin: BigInt(0.525e18),
2328
+ }),
2329
+ )
2330
+ .onCall(1)
2331
+ .resolves(
2332
+ createMockBridgeQuote({
2333
+ fromAmount: perChainInventory,
2334
+ toAmount: BigInt(0.525e18),
2335
+ toAmountMin: BigInt(0.525e18),
2336
+ }),
2337
+ )
2338
+ .onCall(2)
2339
+ .resolves(
2340
+ createMockBridgeQuote({
2341
+ fromAmount: perChainInventory,
2342
+ toAmount: BigInt(0.505e18),
2343
+ toAmountMin: BigInt(0.5e18),
2344
+ }),
2345
+ )
2346
+ .onCall(3)
2347
+ .resolves(
2348
+ createMockBridgeQuote({
2349
+ fromAmount: perChainInventory,
2350
+ toAmount: BigInt(0.515e18),
2351
+ toAmountMin: BigInt(0.51e18),
2352
+ }),
2353
+ );
2354
+
2355
+ bridge.execute
2356
+ .onFirstCall()
2357
+ .resolves({
2358
+ txHash: '0xSuccessTxHash1',
2359
+ fromChain: 42161,
2360
+ toChain: 1399811149,
2361
+ })
2362
+ .onSecondCall()
2363
+ .resolves({
2364
+ txHash: '0xSuccessTxHash2',
2365
+ fromChain: 8453,
2366
+ toChain: 1399811149,
2367
+ });
2368
+
2369
+ const results = await localRebalancer.rebalance([route]);
2370
+
2371
+ expect(results).to.have.lengthOf(1);
2372
+ expect(results[0].success).to.be.true;
2373
+ expect(bridge.execute.callCount).to.equal(2);
2374
+
2375
+ const summaryCall = logger.info
2376
+ .getCalls()
2377
+ .find(
2378
+ (call: ReturnType<typeof logger.info.getCall>) =>
2379
+ call.args[0] &&
2380
+ typeof call.args[0] === 'object' &&
2381
+ Object.prototype.hasOwnProperty.call(
2382
+ call.args[0],
2383
+ 'totalQuotedOutputMin',
2384
+ ),
2385
+ );
2386
+
2387
+ assert(summaryCall, 'Expected summary log with totalQuotedOutputMin');
2388
+ expect(summaryCall.args[0].totalQuotedOutputMin).to.equal(
2389
+ BigInt(1.01e18).toString(),
2390
+ );
1497
2391
  });
1498
2392
 
1499
2393
  it('continues when some bridges fail', async () => {
@@ -1721,14 +2615,36 @@ describe('InventoryRebalancer E2E', () => {
1721
2615
  });
1722
2616
 
1723
2617
  // 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
- );
2618
+ bridge.quote
2619
+ .onFirstCall()
2620
+ .resolves(
2621
+ createMockBridgeQuote({
2622
+ fromAmount: tokenBalance,
2623
+ toAmount: tokenBalance,
2624
+ toAmountMin: tokenBalance,
2625
+ gasCosts: BigInt(1e18), // Very high gas (would fail viability if this were native)
2626
+ feeCosts: 0n,
2627
+ }),
2628
+ )
2629
+ .onSecondCall()
2630
+ .resolves(
2631
+ createMockBridgeQuote({
2632
+ fromAmount: tokenBalance,
2633
+ toAmount: tokenBalance,
2634
+ toAmountMin: tokenBalance,
2635
+ gasCosts: BigInt(1e18),
2636
+ feeCosts: 0n,
2637
+ requestParams: {
2638
+ fromChain: 42161,
2639
+ toChain: 1399811149,
2640
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2641
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2642
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2643
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2644
+ toAmount: tokenBalance,
2645
+ },
2646
+ }),
2647
+ );
1732
2648
 
1733
2649
  bridge.execute.resolves({
1734
2650
  txHash: '0xERC20BridgeTxHash',
@@ -1742,6 +2658,9 @@ describe('InventoryRebalancer E2E', () => {
1742
2658
  expect(results).to.have.lengthOf(1);
1743
2659
  expect(results[0].success).to.be.true;
1744
2660
  expect(bridge.execute.calledOnce).to.be.true;
2661
+ expect(bridge.quote.callCount).to.equal(2);
2662
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(tokenBalance);
2663
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
1745
2664
  });
1746
2665
 
1747
2666
  it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
@@ -1777,14 +2696,47 @@ describe('InventoryRebalancer E2E', () => {
1777
2696
  // maxViable = 1 ETH - 0.02 ETH = 0.98 ETH
1778
2697
  // 10% threshold = 0.1 ETH, estimatedGas (0.02) < threshold → viable
1779
2698
  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
- );
2699
+ const maxViable = rawBalance - gasCosts * 20n;
2700
+ bridge.quote
2701
+ .onFirstCall()
2702
+ .resolves(
2703
+ createMockBridgeQuote({
2704
+ fromAmount: rawBalance,
2705
+ toAmount: rawBalance - BigInt(1e15),
2706
+ toAmountMin: rawBalance - BigInt(1e15),
2707
+ gasCosts,
2708
+ feeCosts: 0n,
2709
+ }),
2710
+ )
2711
+ .onSecondCall()
2712
+ .resolves(
2713
+ createMockBridgeQuote({
2714
+ fromAmount: maxViable,
2715
+ toAmount: maxViable - BigInt(1e15),
2716
+ toAmountMin: maxViable - BigInt(1e15),
2717
+ gasCosts,
2718
+ feeCosts: 0n,
2719
+ }),
2720
+ )
2721
+ .onThirdCall()
2722
+ .resolves(
2723
+ createMockBridgeQuote({
2724
+ fromAmount: BigInt(0.525e18),
2725
+ toAmount: BigInt(0.525e18),
2726
+ toAmountMin: BigInt(0.525e18),
2727
+ gasCosts,
2728
+ feeCosts: 0n,
2729
+ requestParams: {
2730
+ fromChain: 42161,
2731
+ toChain: 1399811149,
2732
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2733
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2734
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2735
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2736
+ toAmount: BigInt(0.525e18),
2737
+ },
2738
+ }),
2739
+ );
1788
2740
 
1789
2741
  bridge.execute.resolves({
1790
2742
  txHash: '0xSuccessBridgeTxHash',
@@ -1798,19 +2750,121 @@ describe('InventoryRebalancer E2E', () => {
1798
2750
  expect(results).to.have.lengthOf(1);
1799
2751
  expect(results[0].success).to.be.true;
1800
2752
  expect(bridge.execute.calledOnce).to.be.true;
2753
+ expect(bridge.quote.callCount).to.equal(3);
2754
+
2755
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(maxViable);
2756
+ const executionTargetOutput = bridge.quote.getCall(2).args[0].toAmount;
2757
+ expect(executionTargetOutput).to.be.a('bigint');
2758
+ if (executionTargetOutput === undefined) {
2759
+ throw new Error('Expected reverse quote to set toAmount');
2760
+ }
2761
+ expect(executionTargetOutput > amount).to.be.true;
2762
+ expect(executionTargetOutput <= maxViable).to.be.true;
2763
+ });
1801
2764
 
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,
2765
+ it('uses forward quote when target output exactly matches source capacity', async () => {
2766
+ const amount = 1000n;
2767
+ const rawBalance = 1100n;
2768
+ const targetWithBuffer = 1050n;
2769
+
2770
+ const route = createTestRoute({ amount });
2771
+ createTestIntent({ amount });
2772
+
2773
+ inventoryRebalancer.setInventoryBalances({
2774
+ [SOLANA_CHAIN]: 0n,
2775
+ [ARBITRUM_CHAIN]: rawBalance,
2776
+ });
2777
+
2778
+ bridge.quote
2779
+ .onFirstCall()
2780
+ .resolves(
2781
+ createMockBridgeQuote({
2782
+ fromAmount: rawBalance,
2783
+ toAmount: targetWithBuffer,
2784
+ toAmountMin: targetWithBuffer,
2785
+ }),
2786
+ )
2787
+ .onSecondCall()
2788
+ .resolves(
2789
+ createMockBridgeQuote({
2790
+ fromAmount: rawBalance,
2791
+ toAmount: targetWithBuffer,
2792
+ toAmountMin: targetWithBuffer,
2793
+ }),
1811
2794
  );
1812
- // Since maxViable (0.98 ETH) > targetWithBuffer (0.525 ETH), we bridge exactly targetWithBuffer
1813
- expect(executionQuoteCall).to.exist;
2795
+
2796
+ bridge.execute.resolves({
2797
+ txHash: '0xForwardBoundaryBridgeTxHash',
2798
+ fromChain: 42161,
2799
+ toChain: 1399811149,
2800
+ });
2801
+
2802
+ const results = await inventoryRebalancer.rebalance([route]);
2803
+
2804
+ expect(results).to.have.lengthOf(1);
2805
+ expect(results[0].success).to.be.true;
2806
+ expect(bridge.execute.calledOnce).to.be.true;
2807
+ expect(bridge.quote.callCount).to.equal(2);
2808
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(rawBalance);
2809
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
2810
+ });
2811
+
2812
+ it('retries reverse quote as forward when exact-output exceeds source capacity', async () => {
2813
+ const amount = 1000n;
2814
+ const rawBalance = 1100n;
2815
+ const targetWithBuffer = 1050n;
2816
+
2817
+ const route = createTestRoute({ amount });
2818
+ createTestIntent({ amount });
2819
+
2820
+ inventoryRebalancer.setInventoryBalances({
2821
+ [SOLANA_CHAIN]: 0n,
2822
+ [ARBITRUM_CHAIN]: rawBalance,
2823
+ });
2824
+
2825
+ bridge.quote
2826
+ .onFirstCall()
2827
+ .resolves(
2828
+ createMockBridgeQuote({
2829
+ fromAmount: rawBalance,
2830
+ toAmount: 1051n,
2831
+ toAmountMin: 1051n,
2832
+ }),
2833
+ )
2834
+ .onSecondCall()
2835
+ .resolves(
2836
+ createMockBridgeQuote({
2837
+ fromAmount: rawBalance + 1n,
2838
+ toAmount: targetWithBuffer,
2839
+ toAmountMin: targetWithBuffer,
2840
+ }),
2841
+ )
2842
+ .onThirdCall()
2843
+ .resolves(
2844
+ createMockBridgeQuote({
2845
+ fromAmount: rawBalance,
2846
+ toAmount: 1049n,
2847
+ toAmountMin: 1049n,
2848
+ }),
2849
+ );
2850
+
2851
+ bridge.execute.resolves({
2852
+ txHash: '0xForwardFallbackBridgeTxHash',
2853
+ fromChain: 42161,
2854
+ toChain: 1399811149,
2855
+ });
2856
+
2857
+ const results = await inventoryRebalancer.rebalance([route]);
2858
+
2859
+ expect(results).to.have.lengthOf(1);
2860
+ expect(results[0].success).to.be.true;
2861
+ expect(bridge.execute.calledOnce).to.be.true;
2862
+ expect(bridge.quote.callCount).to.equal(3);
2863
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(
2864
+ targetWithBuffer,
2865
+ );
2866
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(rawBalance);
2867
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
1814
2868
  });
1815
2869
 
1816
2870
  it('handles quote failures gracefully by skipping the source chain', async () => {