@hyperlane-xyz/rebalancer 0.1.2 → 1.0.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 (223) hide show
  1. package/README.md +134 -14
  2. package/dist/config/RebalancerConfig.d.ts +2 -2
  3. package/dist/config/RebalancerConfig.d.ts.map +1 -1
  4. package/dist/config/RebalancerConfig.js +4 -3
  5. package/dist/config/RebalancerConfig.js.map +1 -1
  6. package/dist/config/RebalancerConfig.test.js +434 -163
  7. package/dist/config/RebalancerConfig.test.js.map +1 -1
  8. package/dist/config/types.d.ts +1650 -290
  9. package/dist/config/types.d.ts.map +1 -1
  10. package/dist/config/types.js +124 -46
  11. package/dist/config/types.js.map +1 -1
  12. package/dist/core/Rebalancer.d.ts +14 -7
  13. package/dist/core/Rebalancer.d.ts.map +1 -1
  14. package/dist/core/Rebalancer.js +168 -99
  15. package/dist/core/Rebalancer.js.map +1 -1
  16. package/dist/core/Rebalancer.test.d.ts +2 -0
  17. package/dist/core/Rebalancer.test.d.ts.map +1 -0
  18. package/dist/core/Rebalancer.test.js +391 -0
  19. package/dist/core/Rebalancer.test.js.map +1 -0
  20. package/dist/core/RebalancerService.d.ts +16 -2
  21. package/dist/core/RebalancerService.d.ts.map +1 -1
  22. package/dist/core/RebalancerService.js +164 -21
  23. package/dist/core/RebalancerService.js.map +1 -1
  24. package/dist/core/RebalancerService.test.d.ts +2 -0
  25. package/dist/core/RebalancerService.test.d.ts.map +1 -0
  26. package/dist/core/RebalancerService.test.js +809 -0
  27. package/dist/core/RebalancerService.test.js.map +1 -0
  28. package/dist/factories/RebalancerContextFactory.d.ts +11 -0
  29. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  30. package/dist/factories/RebalancerContextFactory.js +60 -13
  31. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  32. package/dist/index.d.ts +6 -6
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -3
  35. package/dist/index.js.map +1 -1
  36. package/dist/interfaces/IMonitor.d.ts +6 -8
  37. package/dist/interfaces/IMonitor.d.ts.map +1 -1
  38. package/dist/interfaces/IMonitor.js.map +1 -1
  39. package/dist/interfaces/IRebalancer.d.ts +20 -4
  40. package/dist/interfaces/IRebalancer.d.ts.map +1 -1
  41. package/dist/interfaces/IStrategy.d.ts +18 -2
  42. package/dist/interfaces/IStrategy.d.ts.map +1 -1
  43. package/dist/metrics/Metrics.d.ts +4 -2
  44. package/dist/metrics/Metrics.d.ts.map +1 -1
  45. package/dist/metrics/Metrics.js +21 -1
  46. package/dist/metrics/Metrics.js.map +1 -1
  47. package/dist/metrics/scripts/metrics.d.ts +2 -0
  48. package/dist/metrics/scripts/metrics.d.ts.map +1 -1
  49. package/dist/metrics/scripts/metrics.js +12 -0
  50. package/dist/metrics/scripts/metrics.js.map +1 -1
  51. package/dist/monitor/Monitor.d.ts +8 -3
  52. package/dist/monitor/Monitor.d.ts.map +1 -1
  53. package/dist/monitor/Monitor.js +75 -15
  54. package/dist/monitor/Monitor.js.map +1 -1
  55. package/dist/strategy/BaseStrategy.d.ts +51 -5
  56. package/dist/strategy/BaseStrategy.d.ts.map +1 -1
  57. package/dist/strategy/BaseStrategy.js +199 -19
  58. package/dist/strategy/BaseStrategy.js.map +1 -1
  59. package/dist/strategy/CollateralDeficitStrategy.d.ts +65 -0
  60. package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -0
  61. package/dist/strategy/CollateralDeficitStrategy.js +245 -0
  62. package/dist/strategy/CollateralDeficitStrategy.js.map +1 -0
  63. package/dist/strategy/CollateralDeficitStrategy.test.d.ts +2 -0
  64. package/dist/strategy/CollateralDeficitStrategy.test.d.ts.map +1 -0
  65. package/dist/strategy/CollateralDeficitStrategy.test.js +364 -0
  66. package/dist/strategy/CollateralDeficitStrategy.test.js.map +1 -0
  67. package/dist/strategy/CompositeStrategy.d.ts +18 -0
  68. package/dist/strategy/CompositeStrategy.d.ts.map +1 -0
  69. package/dist/strategy/CompositeStrategy.js +63 -0
  70. package/dist/strategy/CompositeStrategy.js.map +1 -0
  71. package/dist/strategy/CompositeStrategy.test.d.ts +2 -0
  72. package/dist/strategy/CompositeStrategy.test.d.ts.map +1 -0
  73. package/dist/strategy/CompositeStrategy.test.js +265 -0
  74. package/dist/strategy/CompositeStrategy.test.js.map +1 -0
  75. package/dist/strategy/MinAmountStrategy.d.ts +12 -5
  76. package/dist/strategy/MinAmountStrategy.d.ts.map +1 -1
  77. package/dist/strategy/MinAmountStrategy.js +23 -14
  78. package/dist/strategy/MinAmountStrategy.js.map +1 -1
  79. package/dist/strategy/MinAmountStrategy.test.js +88 -20
  80. package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
  81. package/dist/strategy/StrategyFactory.d.ts +15 -6
  82. package/dist/strategy/StrategyFactory.d.ts.map +1 -1
  83. package/dist/strategy/StrategyFactory.js +48 -10
  84. package/dist/strategy/StrategyFactory.js.map +1 -1
  85. package/dist/strategy/StrategyFactory.test.js +2 -2
  86. package/dist/strategy/StrategyFactory.test.js.map +1 -1
  87. package/dist/strategy/WeightedStrategy.d.ts +13 -4
  88. package/dist/strategy/WeightedStrategy.d.ts.map +1 -1
  89. package/dist/strategy/WeightedStrategy.js +18 -6
  90. package/dist/strategy/WeightedStrategy.js.map +1 -1
  91. package/dist/strategy/WeightedStrategy.test.js +108 -18
  92. package/dist/strategy/WeightedStrategy.test.js.map +1 -1
  93. package/dist/strategy/index.d.ts +2 -0
  94. package/dist/strategy/index.d.ts.map +1 -1
  95. package/dist/strategy/index.js +2 -0
  96. package/dist/strategy/index.js.map +1 -1
  97. package/dist/test/helpers.d.ts +93 -3
  98. package/dist/test/helpers.d.ts.map +1 -1
  99. package/dist/test/helpers.js +267 -10
  100. package/dist/test/helpers.js.map +1 -1
  101. package/dist/tracking/ActionTracker.d.ts +49 -0
  102. package/dist/tracking/ActionTracker.d.ts.map +1 -0
  103. package/dist/tracking/ActionTracker.js +422 -0
  104. package/dist/tracking/ActionTracker.js.map +1 -0
  105. package/dist/tracking/ActionTracker.test.d.ts +2 -0
  106. package/dist/tracking/ActionTracker.test.d.ts.map +1 -0
  107. package/dist/tracking/ActionTracker.test.js +637 -0
  108. package/dist/tracking/ActionTracker.test.js.map +1 -0
  109. package/dist/tracking/IActionTracker.d.ts +101 -0
  110. package/dist/tracking/IActionTracker.d.ts.map +1 -0
  111. package/dist/tracking/IActionTracker.js +2 -0
  112. package/dist/tracking/IActionTracker.js.map +1 -0
  113. package/dist/tracking/InflightContextAdapter.d.ts +18 -0
  114. package/dist/tracking/InflightContextAdapter.d.ts.map +1 -0
  115. package/dist/tracking/InflightContextAdapter.js +35 -0
  116. package/dist/tracking/InflightContextAdapter.js.map +1 -0
  117. package/dist/tracking/InflightContextAdapter.test.d.ts +2 -0
  118. package/dist/tracking/InflightContextAdapter.test.d.ts.map +1 -0
  119. package/dist/tracking/InflightContextAdapter.test.js +172 -0
  120. package/dist/tracking/InflightContextAdapter.test.js.map +1 -0
  121. package/dist/tracking/index.d.ts +7 -0
  122. package/dist/tracking/index.d.ts.map +1 -0
  123. package/dist/tracking/index.js +6 -0
  124. package/dist/tracking/index.js.map +1 -0
  125. package/dist/tracking/store/IStore.d.ts +41 -0
  126. package/dist/tracking/store/IStore.d.ts.map +1 -0
  127. package/dist/tracking/store/IStore.js +2 -0
  128. package/dist/tracking/store/IStore.js.map +1 -0
  129. package/dist/tracking/store/InMemoryStore.d.ts +21 -0
  130. package/dist/tracking/store/InMemoryStore.d.ts.map +1 -0
  131. package/dist/tracking/store/InMemoryStore.js +40 -0
  132. package/dist/tracking/store/InMemoryStore.js.map +1 -0
  133. package/dist/tracking/store/InMemoryStore.test.d.ts +2 -0
  134. package/dist/tracking/store/InMemoryStore.test.d.ts.map +1 -0
  135. package/dist/tracking/store/InMemoryStore.test.js +290 -0
  136. package/dist/tracking/store/InMemoryStore.test.js.map +1 -0
  137. package/dist/tracking/store/index.d.ts +3 -0
  138. package/dist/tracking/store/index.d.ts.map +1 -0
  139. package/dist/tracking/store/index.js +2 -0
  140. package/dist/tracking/store/index.js.map +1 -0
  141. package/dist/tracking/types.d.ts +43 -0
  142. package/dist/tracking/types.d.ts.map +1 -0
  143. package/dist/tracking/types.js +2 -0
  144. package/dist/tracking/types.js.map +1 -0
  145. package/dist/utils/ExplorerClient.d.ts +39 -1
  146. package/dist/utils/ExplorerClient.d.ts.map +1 -1
  147. package/dist/utils/ExplorerClient.js +205 -2
  148. package/dist/utils/ExplorerClient.js.map +1 -1
  149. package/dist/utils/balanceUtils.js +2 -2
  150. package/dist/utils/balanceUtils.js.map +1 -1
  151. package/dist/utils/balanceUtils.test.js +1 -0
  152. package/dist/utils/balanceUtils.test.js.map +1 -1
  153. package/dist/utils/bridgeUtils.d.ts +1 -3
  154. package/dist/utils/bridgeUtils.d.ts.map +1 -1
  155. package/dist/utils/bridgeUtils.js +1 -5
  156. package/dist/utils/bridgeUtils.js.map +1 -1
  157. package/dist/utils/bridgeUtils.test.js +3 -14
  158. package/dist/utils/bridgeUtils.test.js.map +1 -1
  159. package/package.json +11 -9
  160. package/src/config/RebalancerConfig.test.ts +459 -163
  161. package/src/config/RebalancerConfig.ts +5 -3
  162. package/src/config/types.ts +159 -52
  163. package/src/core/Rebalancer.test.ts +632 -0
  164. package/src/core/Rebalancer.ts +247 -157
  165. package/src/core/RebalancerService.test.ts +1144 -0
  166. package/src/core/RebalancerService.ts +245 -23
  167. package/src/factories/RebalancerContextFactory.ts +115 -14
  168. package/src/index.ts +16 -4
  169. package/src/interfaces/IMonitor.ts +15 -8
  170. package/src/interfaces/IRebalancer.ts +22 -4
  171. package/src/interfaces/IStrategy.ts +23 -2
  172. package/src/metrics/Metrics.ts +26 -5
  173. package/src/metrics/scripts/metrics.ts +14 -0
  174. package/src/monitor/Monitor.ts +109 -22
  175. package/src/strategy/BaseStrategy.ts +316 -26
  176. package/src/strategy/CollateralDeficitStrategy.test.ts +551 -0
  177. package/src/strategy/CollateralDeficitStrategy.ts +390 -0
  178. package/src/strategy/CompositeStrategy.test.ts +405 -0
  179. package/src/strategy/CompositeStrategy.ts +102 -0
  180. package/src/strategy/MinAmountStrategy.test.ts +189 -88
  181. package/src/strategy/MinAmountStrategy.ts +44 -13
  182. package/src/strategy/StrategyFactory.test.ts +2 -2
  183. package/src/strategy/StrategyFactory.ts +91 -8
  184. package/src/strategy/WeightedStrategy.test.ts +187 -72
  185. package/src/strategy/WeightedStrategy.ts +41 -7
  186. package/src/strategy/index.ts +2 -0
  187. package/src/test/helpers.ts +418 -14
  188. package/src/tracking/ActionTracker.test.ts +783 -0
  189. package/src/tracking/ActionTracker.ts +647 -0
  190. package/src/tracking/IActionTracker.ts +140 -0
  191. package/src/tracking/InflightContextAdapter.test.ts +203 -0
  192. package/src/tracking/InflightContextAdapter.ts +42 -0
  193. package/src/tracking/index.ts +36 -0
  194. package/src/tracking/store/IStore.ts +48 -0
  195. package/src/tracking/store/InMemoryStore.test.ts +338 -0
  196. package/src/tracking/store/InMemoryStore.ts +58 -0
  197. package/src/tracking/store/index.ts +2 -0
  198. package/src/tracking/types.ts +74 -0
  199. package/src/utils/ExplorerClient.ts +266 -3
  200. package/src/utils/balanceUtils.test.ts +1 -0
  201. package/src/utils/balanceUtils.ts +2 -2
  202. package/src/utils/bridgeUtils.test.ts +3 -15
  203. package/src/utils/bridgeUtils.ts +0 -10
  204. package/dist/core/WithInflightGuard.d.ts +0 -20
  205. package/dist/core/WithInflightGuard.d.ts.map +0 -1
  206. package/dist/core/WithInflightGuard.js +0 -47
  207. package/dist/core/WithInflightGuard.js.map +0 -1
  208. package/dist/core/WithInflightGuard.test.d.ts +0 -2
  209. package/dist/core/WithInflightGuard.test.d.ts.map +0 -1
  210. package/dist/core/WithInflightGuard.test.js +0 -64
  211. package/dist/core/WithInflightGuard.test.js.map +0 -1
  212. package/dist/core/WithSemaphore.d.ts +0 -22
  213. package/dist/core/WithSemaphore.d.ts.map +0 -1
  214. package/dist/core/WithSemaphore.js +0 -67
  215. package/dist/core/WithSemaphore.js.map +0 -1
  216. package/dist/core/WithSemaphore.test.d.ts +0 -2
  217. package/dist/core/WithSemaphore.test.d.ts.map +0 -1
  218. package/dist/core/WithSemaphore.test.js +0 -83
  219. package/dist/core/WithSemaphore.test.js.map +0 -1
  220. package/src/core/WithInflightGuard.test.ts +0 -131
  221. package/src/core/WithInflightGuard.ts +0 -67
  222. package/src/core/WithSemaphore.test.ts +0 -111
  223. package/src/core/WithSemaphore.ts +0 -92
@@ -0,0 +1,1144 @@
1
+ import chai, { expect } from 'chai';
2
+ import chaiAsPromised from 'chai-as-promised';
3
+ import { pino } from 'pino';
4
+ import Sinon from 'sinon';
5
+
6
+ import type { MultiProvider, Token, WarpCore } from '@hyperlane-xyz/sdk';
7
+
8
+ import type { RebalancerConfig } from '../config/RebalancerConfig.js';
9
+ import { RebalancerStrategyOptions } from '../config/types.js';
10
+ import { RebalancerContextFactory } from '../factories/RebalancerContextFactory.js';
11
+ import { MonitorEventType } from '../interfaces/IMonitor.js';
12
+ import type { IRebalancer } from '../interfaces/IRebalancer.js';
13
+ import type { IStrategy } from '../interfaces/IStrategy.js';
14
+ import { Metrics } from '../metrics/Metrics.js';
15
+ import { Monitor } from '../monitor/Monitor.js';
16
+ import { TEST_ADDRESSES, getTestAddress } from '../test/helpers.js';
17
+ import type { IActionTracker } from '../tracking/index.js';
18
+ import { InflightContextAdapter } from '../tracking/index.js';
19
+
20
+ import {
21
+ RebalancerService,
22
+ type RebalancerServiceConfig,
23
+ } from './RebalancerService.js';
24
+
25
+ chai.use(chaiAsPromised);
26
+
27
+ const testLogger = pino({ level: 'silent' });
28
+
29
+ function createMockRebalancerConfig(): RebalancerConfig {
30
+ return {
31
+ warpRouteId: 'TEST/route',
32
+ strategyConfig: [
33
+ {
34
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
35
+ chains: {
36
+ ethereum: {
37
+ bridge: TEST_ADDRESSES.bridge,
38
+ bridgeMinAcceptedAmount: 0,
39
+ weighted: { weight: 50n, tolerance: 10n },
40
+ },
41
+ arbitrum: {
42
+ bridge: TEST_ADDRESSES.bridge,
43
+ bridgeMinAcceptedAmount: 0,
44
+ weighted: { weight: 50n, tolerance: 10n },
45
+ },
46
+ },
47
+ },
48
+ ],
49
+ } as RebalancerConfig;
50
+ }
51
+
52
+ function createMockMultiProvider(): MultiProvider {
53
+ return {
54
+ getDomainId: Sinon.stub().callsFake((chain: string) => {
55
+ const domains: Record<string, number> = { ethereum: 1, arbitrum: 42161 };
56
+ return domains[chain] ?? 0;
57
+ }),
58
+ getSigner: Sinon.stub().returns({
59
+ getAddress: Sinon.stub().resolves(TEST_ADDRESSES.signer),
60
+ }),
61
+ metadata: {
62
+ ethereum: { domainId: 1 },
63
+ arbitrum: { domainId: 42161 },
64
+ },
65
+ } as unknown as MultiProvider;
66
+ }
67
+
68
+ function createMockToken(chainName: string): Token {
69
+ return {
70
+ chainName,
71
+ name: `${chainName}Token`,
72
+ decimals: 18,
73
+ addressOrDenom: getTestAddress(chainName),
74
+ standard: 'EvmHypCollateral',
75
+ isCollateralized: () => true,
76
+ } as unknown as Token;
77
+ }
78
+
79
+ function createMockWarpCore(): WarpCore {
80
+ return {
81
+ tokens: [createMockToken('ethereum'), createMockToken('arbitrum')],
82
+ multiProvider: createMockMultiProvider(),
83
+ } as unknown as WarpCore;
84
+ }
85
+
86
+ function createMockRebalancer(): IRebalancer & { rebalance: Sinon.SinonStub } {
87
+ return {
88
+ rebalance: Sinon.stub().resolves([]),
89
+ };
90
+ }
91
+
92
+ function createMockStrategy(): IStrategy & {
93
+ getRebalancingRoutes: Sinon.SinonStub;
94
+ } {
95
+ return {
96
+ name: 'mock-strategy',
97
+ getRebalancingRoutes: Sinon.stub().returns([]),
98
+ };
99
+ }
100
+
101
+ function createMockActionTracker(): IActionTracker {
102
+ return {
103
+ initialize: Sinon.stub().resolves(),
104
+ createRebalanceIntent: Sinon.stub().callsFake(async () => ({
105
+ id: `intent-${Date.now()}`,
106
+ status: 'not_started',
107
+ })),
108
+ createRebalanceAction: Sinon.stub().resolves(),
109
+ completeRebalanceAction: Sinon.stub().resolves(),
110
+ failRebalanceAction: Sinon.stub().resolves(),
111
+ completeRebalanceIntent: Sinon.stub().resolves(),
112
+ cancelRebalanceIntent: Sinon.stub().resolves(),
113
+ failRebalanceIntent: Sinon.stub().resolves(),
114
+ syncTransfers: Sinon.stub().resolves(),
115
+ syncRebalanceIntents: Sinon.stub().resolves(),
116
+ syncRebalanceActions: Sinon.stub().resolves(),
117
+ logStoreContents: Sinon.stub().resolves(),
118
+ getInProgressTransfers: Sinon.stub().resolves([]),
119
+ getActiveRebalanceIntents: Sinon.stub().resolves([]),
120
+ getTransfersByDestination: Sinon.stub().resolves([]),
121
+ getRebalanceIntentsByDestination: Sinon.stub().resolves([]),
122
+ };
123
+ }
124
+
125
+ function createMockInflightContextAdapter(): InflightContextAdapter & {
126
+ getInflightContext: Sinon.SinonStub;
127
+ } {
128
+ return {
129
+ getInflightContext: Sinon.stub().resolves({
130
+ pendingRebalances: [],
131
+ pendingTransfers: [],
132
+ }),
133
+ } as unknown as InflightContextAdapter & {
134
+ getInflightContext: Sinon.SinonStub;
135
+ };
136
+ }
137
+
138
+ function createMockContextFactory(
139
+ overrides: {
140
+ warpCore?: WarpCore;
141
+ rebalancer?: IRebalancer;
142
+ strategy?: IStrategy;
143
+ actionTracker?: IActionTracker;
144
+ inflightAdapter?: InflightContextAdapter;
145
+ monitor?: Monitor;
146
+ metrics?: Metrics;
147
+ } = {},
148
+ ): RebalancerContextFactory {
149
+ const warpCore = overrides.warpCore ?? createMockWarpCore();
150
+ const rebalancer = overrides.rebalancer ?? createMockRebalancer();
151
+ const strategy = overrides.strategy ?? createMockStrategy();
152
+ const actionTracker = overrides.actionTracker ?? createMockActionTracker();
153
+ const inflightAdapter =
154
+ overrides.inflightAdapter ?? createMockInflightContextAdapter();
155
+ const monitor =
156
+ overrides.monitor ??
157
+ ({
158
+ on: Sinon.stub().returnsThis(),
159
+ start: Sinon.stub().resolves(),
160
+ stop: Sinon.stub().resolves(),
161
+ } as unknown as Monitor);
162
+
163
+ return {
164
+ getWarpCore: () => warpCore,
165
+ getTokenForChain: (chain: string) =>
166
+ warpCore.tokens.find((t) => t.chainName === chain),
167
+ createRebalancer: () => rebalancer,
168
+ createStrategy: async () => strategy,
169
+ createMonitor: () => monitor,
170
+ createMetrics: async () => overrides.metrics ?? ({} as Metrics),
171
+ createActionTracker: async () => ({
172
+ tracker: actionTracker,
173
+ adapter: inflightAdapter,
174
+ }),
175
+ } as unknown as RebalancerContextFactory;
176
+ }
177
+
178
+ interface DaemonTestSetup {
179
+ actionTracker: IActionTracker;
180
+ rebalancer: IRebalancer & { rebalance: Sinon.SinonStub };
181
+ strategy: IStrategy & { getRebalancingRoutes: Sinon.SinonStub };
182
+ triggerCycle: () => Promise<void>;
183
+ }
184
+
185
+ async function setupDaemonTest(
186
+ sandbox: Sinon.SinonSandbox,
187
+ options: {
188
+ intentIds?: string[];
189
+ rebalanceResults: Array<{
190
+ route: {
191
+ origin: string;
192
+ destination: string;
193
+ amount: bigint;
194
+ intentId: string;
195
+ bridge: string;
196
+ };
197
+ success: boolean;
198
+ messageId?: string;
199
+ txHash?: string;
200
+ error?: string;
201
+ }>;
202
+ strategyRoutes: Array<{
203
+ origin: string;
204
+ destination: string;
205
+ amount: bigint;
206
+ bridge: string;
207
+ }>;
208
+ },
209
+ ): Promise<DaemonTestSetup> {
210
+ const actionTracker = createMockActionTracker();
211
+ let intentIndex = 0;
212
+ (actionTracker.createRebalanceIntent as Sinon.SinonStub).callsFake(
213
+ async () => ({
214
+ id: options.intentIds?.[intentIndex] ?? `intent-${intentIndex + 1}`,
215
+ status: 'not_started' as const,
216
+ ...(intentIndex++, {}),
217
+ }),
218
+ );
219
+
220
+ const rebalancer = createMockRebalancer();
221
+ rebalancer.rebalance.resolves(options.rebalanceResults);
222
+
223
+ const strategy = createMockStrategy();
224
+ strategy.getRebalancingRoutes.returns(options.strategyRoutes);
225
+
226
+ const inflightAdapter = createMockInflightContextAdapter();
227
+
228
+ let tokenInfoHandler: ((event: any) => Promise<void>) | undefined;
229
+ const monitor = {
230
+ on: Sinon.stub().callsFake((event: string, handler: any) => {
231
+ if (event === MonitorEventType.TokenInfo) {
232
+ tokenInfoHandler = handler;
233
+ }
234
+ return monitor;
235
+ }),
236
+ start: Sinon.stub().resolves(),
237
+ stop: Sinon.stub().resolves(),
238
+ } as unknown as Monitor;
239
+
240
+ const contextFactory = createMockContextFactory({
241
+ rebalancer,
242
+ strategy,
243
+ actionTracker,
244
+ inflightAdapter,
245
+ monitor,
246
+ });
247
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
248
+
249
+ const service = new RebalancerService(
250
+ createMockMultiProvider(),
251
+ undefined,
252
+ {} as any,
253
+ createMockRebalancerConfig(),
254
+ { mode: 'daemon', checkFrequency: 60000, logger: testLogger },
255
+ );
256
+
257
+ await service.start();
258
+
259
+ return {
260
+ actionTracker,
261
+ rebalancer,
262
+ strategy,
263
+ triggerCycle: async () => {
264
+ expect(tokenInfoHandler).to.not.be.undefined;
265
+ await tokenInfoHandler!({
266
+ tokensInfo: [
267
+ { token: createMockToken('ethereum'), bridgedSupply: 5000n },
268
+ { token: createMockToken('arbitrum'), bridgedSupply: 5000n },
269
+ ],
270
+ });
271
+ },
272
+ };
273
+ }
274
+
275
+ describe('RebalancerService', () => {
276
+ let sandbox: Sinon.SinonSandbox;
277
+
278
+ beforeEach(() => {
279
+ sandbox = Sinon.createSandbox();
280
+ });
281
+
282
+ afterEach(() => {
283
+ sandbox.restore();
284
+ });
285
+
286
+ describe('executeManual()', () => {
287
+ it('should execute manual rebalance successfully', async () => {
288
+ const rebalancer = createMockRebalancer();
289
+ rebalancer.rebalance.resolves([
290
+ {
291
+ route: { origin: 'ethereum', destination: 'arbitrum', amount: 1000n },
292
+ success: true,
293
+ messageId:
294
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
295
+ txHash:
296
+ '0x2222222222222222222222222222222222222222222222222222222222222222',
297
+ },
298
+ ]);
299
+
300
+ const contextFactory = createMockContextFactory({ rebalancer });
301
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
302
+
303
+ const config: RebalancerServiceConfig = {
304
+ mode: 'manual',
305
+ logger: testLogger,
306
+ };
307
+
308
+ const service = new RebalancerService(
309
+ createMockMultiProvider(),
310
+ undefined,
311
+ {} as any,
312
+ createMockRebalancerConfig(),
313
+ config,
314
+ );
315
+
316
+ await service.executeManual({
317
+ origin: 'ethereum',
318
+ destination: 'arbitrum',
319
+ amount: '100',
320
+ });
321
+
322
+ expect(rebalancer.rebalance.calledOnce).to.be.true;
323
+ const calledRoutes = rebalancer.rebalance.firstCall.args[0];
324
+ expect(calledRoutes).to.have.lengthOf(1);
325
+ expect(calledRoutes[0].origin).to.equal('ethereum');
326
+ expect(calledRoutes[0].destination).to.equal('arbitrum');
327
+ });
328
+
329
+ it('should throw when origin token not found', async () => {
330
+ const warpCore = {
331
+ tokens: [createMockToken('arbitrum')],
332
+ multiProvider: createMockMultiProvider(),
333
+ } as unknown as WarpCore;
334
+
335
+ const contextFactory = createMockContextFactory({ warpCore });
336
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
337
+
338
+ const config: RebalancerServiceConfig = {
339
+ mode: 'manual',
340
+ logger: testLogger,
341
+ };
342
+
343
+ const service = new RebalancerService(
344
+ createMockMultiProvider(),
345
+ undefined,
346
+ {} as any,
347
+ createMockRebalancerConfig(),
348
+ config,
349
+ );
350
+
351
+ await expect(
352
+ service.executeManual({
353
+ origin: 'ethereum',
354
+ destination: 'arbitrum',
355
+ amount: '100',
356
+ }),
357
+ ).to.be.rejectedWith('Origin token not found');
358
+ });
359
+
360
+ it('should throw when amount is invalid', async () => {
361
+ const contextFactory = createMockContextFactory();
362
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
363
+
364
+ const config: RebalancerServiceConfig = {
365
+ mode: 'manual',
366
+ logger: testLogger,
367
+ };
368
+
369
+ const service = new RebalancerService(
370
+ createMockMultiProvider(),
371
+ undefined,
372
+ {} as any,
373
+ createMockRebalancerConfig(),
374
+ config,
375
+ );
376
+
377
+ await expect(
378
+ service.executeManual({
379
+ origin: 'ethereum',
380
+ destination: 'arbitrum',
381
+ amount: 'invalid',
382
+ }),
383
+ ).to.be.rejectedWith('Amount must be a valid number');
384
+ });
385
+
386
+ it('should throw when amount is zero or negative', async () => {
387
+ const contextFactory = createMockContextFactory();
388
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
389
+
390
+ const config: RebalancerServiceConfig = {
391
+ mode: 'manual',
392
+ logger: testLogger,
393
+ };
394
+
395
+ const service = new RebalancerService(
396
+ createMockMultiProvider(),
397
+ undefined,
398
+ {} as any,
399
+ createMockRebalancerConfig(),
400
+ config,
401
+ );
402
+
403
+ await expect(
404
+ service.executeManual({
405
+ origin: 'ethereum',
406
+ destination: 'arbitrum',
407
+ amount: '0',
408
+ }),
409
+ ).to.be.rejectedWith('Amount must be greater than 0');
410
+
411
+ await expect(
412
+ service.executeManual({
413
+ origin: 'ethereum',
414
+ destination: 'arbitrum',
415
+ amount: '-100',
416
+ }),
417
+ ).to.be.rejectedWith('Amount must be greater than 0');
418
+ });
419
+
420
+ it('should throw when origin chain has no bridge configured', async () => {
421
+ const contextFactory = createMockContextFactory();
422
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
423
+
424
+ const configWithoutBridge: RebalancerConfig = {
425
+ warpRouteId: 'TEST/route',
426
+ strategyConfig: [
427
+ {
428
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
429
+ chains: {
430
+ arbitrum: {
431
+ bridge: TEST_ADDRESSES.bridge,
432
+ bridgeMinAcceptedAmount: 0,
433
+ weighted: { weight: 100n, tolerance: 10n },
434
+ },
435
+ },
436
+ },
437
+ ],
438
+ } as RebalancerConfig;
439
+
440
+ const config: RebalancerServiceConfig = {
441
+ mode: 'manual',
442
+ logger: testLogger,
443
+ };
444
+
445
+ const service = new RebalancerService(
446
+ createMockMultiProvider(),
447
+ undefined,
448
+ {} as any,
449
+ configWithoutBridge,
450
+ config,
451
+ );
452
+
453
+ await expect(
454
+ service.executeManual({
455
+ origin: 'ethereum',
456
+ destination: 'arbitrum',
457
+ amount: '100',
458
+ }),
459
+ ).to.be.rejectedWith('No bridge configured for origin chain ethereum');
460
+ });
461
+
462
+ it('should throw when in monitorOnly mode', async () => {
463
+ const contextFactory = createMockContextFactory();
464
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
465
+
466
+ const config: RebalancerServiceConfig = {
467
+ mode: 'manual',
468
+ monitorOnly: true,
469
+ logger: testLogger,
470
+ };
471
+
472
+ const service = new RebalancerService(
473
+ createMockMultiProvider(),
474
+ undefined,
475
+ {} as any,
476
+ createMockRebalancerConfig(),
477
+ config,
478
+ );
479
+
480
+ await expect(
481
+ service.executeManual({
482
+ origin: 'ethereum',
483
+ destination: 'arbitrum',
484
+ amount: '100',
485
+ }),
486
+ ).to.be.rejectedWith('MonitorOnly mode cannot execute manual rebalances');
487
+ });
488
+
489
+ it('should propagate errors from rebalancer', async () => {
490
+ const rebalancer = createMockRebalancer();
491
+ rebalancer.rebalance.rejects(new Error('Rebalance failed'));
492
+
493
+ const contextFactory = createMockContextFactory({ rebalancer });
494
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
495
+
496
+ const config: RebalancerServiceConfig = {
497
+ mode: 'manual',
498
+ logger: testLogger,
499
+ };
500
+
501
+ const service = new RebalancerService(
502
+ createMockMultiProvider(),
503
+ undefined,
504
+ {} as any,
505
+ createMockRebalancerConfig(),
506
+ config,
507
+ );
508
+
509
+ await expect(
510
+ service.executeManual({
511
+ origin: 'ethereum',
512
+ destination: 'arbitrum',
513
+ amount: '100',
514
+ }),
515
+ ).to.be.rejectedWith('Rebalance failed');
516
+ });
517
+ });
518
+
519
+ describe('start()', () => {
520
+ it('should throw when not in daemon mode', async () => {
521
+ const config: RebalancerServiceConfig = {
522
+ mode: 'manual',
523
+ logger: testLogger,
524
+ };
525
+
526
+ const service = new RebalancerService(
527
+ createMockMultiProvider(),
528
+ undefined,
529
+ {} as any,
530
+ createMockRebalancerConfig(),
531
+ config,
532
+ );
533
+
534
+ await expect(service.start()).to.be.rejectedWith(
535
+ 'start() can only be called in daemon mode',
536
+ );
537
+ });
538
+
539
+ it('should start monitor in daemon mode', async () => {
540
+ const monitor = {
541
+ on: Sinon.stub().returnsThis(),
542
+ start: Sinon.stub().resolves(),
543
+ stop: Sinon.stub().resolves(),
544
+ } as unknown as Monitor;
545
+
546
+ const contextFactory = createMockContextFactory({ monitor });
547
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
548
+
549
+ const config: RebalancerServiceConfig = {
550
+ mode: 'daemon',
551
+ checkFrequency: 60000,
552
+ logger: testLogger,
553
+ };
554
+
555
+ const service = new RebalancerService(
556
+ createMockMultiProvider(),
557
+ undefined,
558
+ {} as any,
559
+ createMockRebalancerConfig(),
560
+ config,
561
+ );
562
+
563
+ await service.start();
564
+
565
+ expect((monitor.on as Sinon.SinonStub).called).to.be.true;
566
+ expect((monitor.start as Sinon.SinonStub).calledOnce).to.be.true;
567
+ });
568
+ });
569
+
570
+ describe('stop()', () => {
571
+ it('should stop monitor', async () => {
572
+ const monitor = {
573
+ on: Sinon.stub().returnsThis(),
574
+ start: Sinon.stub().resolves(),
575
+ stop: Sinon.stub().resolves(),
576
+ } as unknown as Monitor;
577
+
578
+ const contextFactory = createMockContextFactory({ monitor });
579
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
580
+
581
+ const config: RebalancerServiceConfig = {
582
+ mode: 'daemon',
583
+ checkFrequency: 60000,
584
+ logger: testLogger,
585
+ };
586
+
587
+ const service = new RebalancerService(
588
+ createMockMultiProvider(),
589
+ undefined,
590
+ {} as any,
591
+ createMockRebalancerConfig(),
592
+ config,
593
+ );
594
+
595
+ await service.start();
596
+ await service.stop();
597
+
598
+ expect((monitor.stop as Sinon.SinonStub).calledOnce).to.be.true;
599
+ });
600
+ });
601
+
602
+ describe('daemon mode metrics', () => {
603
+ it('should record failure metric when rebalance has failed results', async () => {
604
+ const rebalancer = createMockRebalancer();
605
+ rebalancer.rebalance.resolves([
606
+ {
607
+ route: {
608
+ origin: 'ethereum',
609
+ destination: 'arbitrum',
610
+ amount: 1000n,
611
+ intentId: 'intent-1',
612
+ bridge: TEST_ADDRESSES.bridge,
613
+ },
614
+ success: false,
615
+ error: 'Gas estimation failed',
616
+ },
617
+ ]);
618
+
619
+ const strategy = createMockStrategy();
620
+ strategy.getRebalancingRoutes.returns([
621
+ {
622
+ origin: 'ethereum',
623
+ destination: 'arbitrum',
624
+ amount: 1000n,
625
+ bridge: TEST_ADDRESSES.bridge,
626
+ },
627
+ ]);
628
+
629
+ const actionTracker = createMockActionTracker();
630
+ const inflightAdapter = createMockInflightContextAdapter();
631
+
632
+ const recordRebalancerSuccess = Sinon.stub();
633
+ const recordRebalancerFailure = Sinon.stub();
634
+ const metrics = {
635
+ recordRebalancerSuccess,
636
+ recordRebalancerFailure,
637
+ recordIntentCreated: Sinon.stub(),
638
+ processToken: Sinon.stub().resolves(),
639
+ } as unknown as Metrics;
640
+
641
+ let tokenInfoHandler: ((event: any) => Promise<void>) | undefined;
642
+ const monitor = {
643
+ on: Sinon.stub().callsFake((event: string, handler: any) => {
644
+ if (event === MonitorEventType.TokenInfo) {
645
+ tokenInfoHandler = handler;
646
+ }
647
+ return monitor;
648
+ }),
649
+ start: Sinon.stub().resolves(),
650
+ stop: Sinon.stub().resolves(),
651
+ } as unknown as Monitor;
652
+
653
+ const contextFactory = createMockContextFactory({
654
+ rebalancer,
655
+ strategy,
656
+ actionTracker,
657
+ inflightAdapter,
658
+ monitor,
659
+ metrics,
660
+ });
661
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
662
+
663
+ const config: RebalancerServiceConfig = {
664
+ mode: 'daemon',
665
+ checkFrequency: 60000,
666
+ withMetrics: true,
667
+ logger: testLogger,
668
+ };
669
+
670
+ const service = new RebalancerService(
671
+ createMockMultiProvider(),
672
+ undefined,
673
+ {} as any,
674
+ createMockRebalancerConfig(),
675
+ config,
676
+ );
677
+
678
+ await service.start();
679
+
680
+ expect(tokenInfoHandler).to.not.be.undefined;
681
+ await tokenInfoHandler!({
682
+ tokensInfo: [
683
+ { token: createMockToken('ethereum'), bridgedSupply: 5000n },
684
+ { token: createMockToken('arbitrum'), bridgedSupply: 5000n },
685
+ ],
686
+ });
687
+
688
+ expect(recordRebalancerFailure.calledOnce).to.be.true;
689
+ expect(recordRebalancerSuccess.called).to.be.false;
690
+ });
691
+
692
+ it('should record success metric when all rebalance results succeed', async () => {
693
+ const rebalancer = createMockRebalancer();
694
+ rebalancer.rebalance.resolves([
695
+ {
696
+ route: {
697
+ origin: 'ethereum',
698
+ destination: 'arbitrum',
699
+ amount: 1000n,
700
+ intentId: 'intent-1',
701
+ bridge: TEST_ADDRESSES.bridge,
702
+ },
703
+ success: true,
704
+ messageId:
705
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
706
+ txHash:
707
+ '0x2222222222222222222222222222222222222222222222222222222222222222',
708
+ },
709
+ ]);
710
+
711
+ const strategy = createMockStrategy();
712
+ strategy.getRebalancingRoutes.returns([
713
+ {
714
+ origin: 'ethereum',
715
+ destination: 'arbitrum',
716
+ amount: 1000n,
717
+ bridge: TEST_ADDRESSES.bridge,
718
+ },
719
+ ]);
720
+
721
+ const actionTracker = createMockActionTracker();
722
+ const inflightAdapter = createMockInflightContextAdapter();
723
+
724
+ const recordRebalancerSuccess = Sinon.stub();
725
+ const recordRebalancerFailure = Sinon.stub();
726
+ const metrics = {
727
+ recordRebalancerSuccess,
728
+ recordRebalancerFailure,
729
+ recordIntentCreated: Sinon.stub(),
730
+ processToken: Sinon.stub().resolves(),
731
+ } as unknown as Metrics;
732
+
733
+ let tokenInfoHandler: ((event: any) => Promise<void>) | undefined;
734
+ const monitor = {
735
+ on: Sinon.stub().callsFake((event: string, handler: any) => {
736
+ if (event === MonitorEventType.TokenInfo) {
737
+ tokenInfoHandler = handler;
738
+ }
739
+ return monitor;
740
+ }),
741
+ start: Sinon.stub().resolves(),
742
+ stop: Sinon.stub().resolves(),
743
+ } as unknown as Monitor;
744
+
745
+ const contextFactory = createMockContextFactory({
746
+ rebalancer,
747
+ strategy,
748
+ actionTracker,
749
+ inflightAdapter,
750
+ monitor,
751
+ metrics,
752
+ });
753
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
754
+
755
+ const config: RebalancerServiceConfig = {
756
+ mode: 'daemon',
757
+ checkFrequency: 60000,
758
+ withMetrics: true,
759
+ logger: testLogger,
760
+ };
761
+
762
+ const service = new RebalancerService(
763
+ createMockMultiProvider(),
764
+ undefined,
765
+ {} as any,
766
+ createMockRebalancerConfig(),
767
+ config,
768
+ );
769
+
770
+ await service.start();
771
+
772
+ expect(tokenInfoHandler).to.not.be.undefined;
773
+ await tokenInfoHandler!({
774
+ tokensInfo: [
775
+ { token: createMockToken('ethereum'), bridgedSupply: 5000n },
776
+ { token: createMockToken('arbitrum'), bridgedSupply: 5000n },
777
+ ],
778
+ });
779
+
780
+ expect(recordRebalancerSuccess.calledOnce).to.be.true;
781
+ expect(recordRebalancerFailure.called).to.be.false;
782
+ });
783
+
784
+ it('should record failure metric when rebalance has mixed results', async () => {
785
+ const rebalancer = createMockRebalancer();
786
+ rebalancer.rebalance.resolves([
787
+ {
788
+ route: {
789
+ origin: 'ethereum',
790
+ destination: 'arbitrum',
791
+ amount: 1000n,
792
+ intentId: 'intent-1',
793
+ bridge: TEST_ADDRESSES.bridge,
794
+ },
795
+ success: true,
796
+ messageId:
797
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
798
+ txHash:
799
+ '0x2222222222222222222222222222222222222222222222222222222222222222',
800
+ },
801
+ {
802
+ route: {
803
+ origin: 'arbitrum',
804
+ destination: 'ethereum',
805
+ amount: 500n,
806
+ intentId: 'intent-2',
807
+ bridge: TEST_ADDRESSES.bridge,
808
+ },
809
+ success: false,
810
+ error: 'Insufficient balance',
811
+ },
812
+ ]);
813
+
814
+ const strategy = createMockStrategy();
815
+ strategy.getRebalancingRoutes.returns([
816
+ {
817
+ origin: 'ethereum',
818
+ destination: 'arbitrum',
819
+ amount: 1000n,
820
+ bridge: TEST_ADDRESSES.bridge,
821
+ },
822
+ {
823
+ origin: 'arbitrum',
824
+ destination: 'ethereum',
825
+ amount: 500n,
826
+ bridge: TEST_ADDRESSES.bridge,
827
+ },
828
+ ]);
829
+
830
+ const actionTracker = createMockActionTracker();
831
+ const inflightAdapter = createMockInflightContextAdapter();
832
+
833
+ const recordRebalancerSuccess = Sinon.stub();
834
+ const recordRebalancerFailure = Sinon.stub();
835
+ const metrics = {
836
+ recordRebalancerSuccess,
837
+ recordRebalancerFailure,
838
+ recordIntentCreated: Sinon.stub(),
839
+ processToken: Sinon.stub().resolves(),
840
+ } as unknown as Metrics;
841
+
842
+ let tokenInfoHandler: ((event: any) => Promise<void>) | undefined;
843
+ const monitor = {
844
+ on: Sinon.stub().callsFake((event: string, handler: any) => {
845
+ if (event === MonitorEventType.TokenInfo) {
846
+ tokenInfoHandler = handler;
847
+ }
848
+ return monitor;
849
+ }),
850
+ start: Sinon.stub().resolves(),
851
+ stop: Sinon.stub().resolves(),
852
+ } as unknown as Monitor;
853
+
854
+ const contextFactory = createMockContextFactory({
855
+ rebalancer,
856
+ strategy,
857
+ actionTracker,
858
+ inflightAdapter,
859
+ monitor,
860
+ metrics,
861
+ });
862
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
863
+
864
+ const config: RebalancerServiceConfig = {
865
+ mode: 'daemon',
866
+ checkFrequency: 60000,
867
+ withMetrics: true,
868
+ logger: testLogger,
869
+ };
870
+
871
+ const service = new RebalancerService(
872
+ createMockMultiProvider(),
873
+ undefined,
874
+ {} as any,
875
+ createMockRebalancerConfig(),
876
+ config,
877
+ );
878
+
879
+ await service.start();
880
+
881
+ expect(tokenInfoHandler).to.not.be.undefined;
882
+ await tokenInfoHandler!({
883
+ tokensInfo: [
884
+ { token: createMockToken('ethereum'), bridgedSupply: 5000n },
885
+ { token: createMockToken('arbitrum'), bridgedSupply: 5000n },
886
+ ],
887
+ });
888
+
889
+ expect(recordRebalancerFailure.calledOnce).to.be.true;
890
+ expect(recordRebalancerSuccess.called).to.be.false;
891
+ });
892
+ });
893
+
894
+ describe('daemon mode intent tracking', () => {
895
+ it('should call failRebalanceIntent with correct intentId when route fails', async () => {
896
+ const { actionTracker, triggerCycle } = await setupDaemonTest(sandbox, {
897
+ intentIds: ['intent-123'],
898
+ rebalanceResults: [
899
+ {
900
+ route: {
901
+ origin: 'ethereum',
902
+ destination: 'arbitrum',
903
+ amount: 1000n,
904
+ intentId: 'intent-123',
905
+ bridge: TEST_ADDRESSES.bridge,
906
+ },
907
+ success: false,
908
+ error: 'Gas estimation failed',
909
+ },
910
+ ],
911
+ strategyRoutes: [
912
+ {
913
+ origin: 'ethereum',
914
+ destination: 'arbitrum',
915
+ amount: 1000n,
916
+ bridge: TEST_ADDRESSES.bridge,
917
+ },
918
+ ],
919
+ });
920
+
921
+ await triggerCycle();
922
+
923
+ expect((actionTracker.failRebalanceIntent as Sinon.SinonStub).calledOnce)
924
+ .to.be.true;
925
+ expect(
926
+ (actionTracker.failRebalanceIntent as Sinon.SinonStub).calledWith(
927
+ 'intent-123',
928
+ ),
929
+ ).to.be.true;
930
+ });
931
+
932
+ it('should call createRebalanceAction with correct intentId when route succeeds', async () => {
933
+ const { actionTracker, triggerCycle } = await setupDaemonTest(sandbox, {
934
+ intentIds: ['intent-456'],
935
+ rebalanceResults: [
936
+ {
937
+ route: {
938
+ origin: 'ethereum',
939
+ destination: 'arbitrum',
940
+ amount: 1000n,
941
+ intentId: 'intent-456',
942
+ bridge: TEST_ADDRESSES.bridge,
943
+ },
944
+ success: true,
945
+ messageId:
946
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
947
+ txHash:
948
+ '0x2222222222222222222222222222222222222222222222222222222222222222',
949
+ },
950
+ ],
951
+ strategyRoutes: [
952
+ {
953
+ origin: 'ethereum',
954
+ destination: 'arbitrum',
955
+ amount: 1000n,
956
+ bridge: TEST_ADDRESSES.bridge,
957
+ },
958
+ ],
959
+ });
960
+
961
+ await triggerCycle();
962
+
963
+ expect(
964
+ (actionTracker.createRebalanceAction as Sinon.SinonStub).calledOnce,
965
+ ).to.be.true;
966
+ const callArgs = (actionTracker.createRebalanceAction as Sinon.SinonStub)
967
+ .firstCall.args[0];
968
+ expect(callArgs.intentId).to.equal('intent-456');
969
+ });
970
+
971
+ it('should handle mixed success/failure results with correct intent mapping', async () => {
972
+ const { actionTracker, triggerCycle } = await setupDaemonTest(sandbox, {
973
+ intentIds: ['intent-1', 'intent-2'],
974
+ rebalanceResults: [
975
+ {
976
+ route: {
977
+ origin: 'ethereum',
978
+ destination: 'arbitrum',
979
+ amount: 1000n,
980
+ intentId: 'intent-1',
981
+ bridge: TEST_ADDRESSES.bridge,
982
+ },
983
+ success: true,
984
+ messageId:
985
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
986
+ txHash:
987
+ '0x2222222222222222222222222222222222222222222222222222222222222222',
988
+ },
989
+ {
990
+ route: {
991
+ origin: 'arbitrum',
992
+ destination: 'ethereum',
993
+ amount: 500n,
994
+ intentId: 'intent-2',
995
+ bridge: TEST_ADDRESSES.bridge,
996
+ },
997
+ success: false,
998
+ error: 'Insufficient funds',
999
+ },
1000
+ ],
1001
+ strategyRoutes: [
1002
+ {
1003
+ origin: 'ethereum',
1004
+ destination: 'arbitrum',
1005
+ amount: 1000n,
1006
+ bridge: TEST_ADDRESSES.bridge,
1007
+ },
1008
+ {
1009
+ origin: 'arbitrum',
1010
+ destination: 'ethereum',
1011
+ amount: 500n,
1012
+ bridge: TEST_ADDRESSES.bridge,
1013
+ },
1014
+ ],
1015
+ });
1016
+
1017
+ await triggerCycle();
1018
+
1019
+ // Verify createRebalanceAction called for intent-1 (success)
1020
+ expect(
1021
+ (actionTracker.createRebalanceAction as Sinon.SinonStub).calledOnce,
1022
+ ).to.be.true;
1023
+ expect(
1024
+ (actionTracker.createRebalanceAction as Sinon.SinonStub).firstCall
1025
+ .args[0].intentId,
1026
+ ).to.equal('intent-1');
1027
+
1028
+ // Verify failRebalanceIntent called for intent-2 (failure)
1029
+ expect((actionTracker.failRebalanceIntent as Sinon.SinonStub).calledOnce)
1030
+ .to.be.true;
1031
+ expect(
1032
+ (actionTracker.failRebalanceIntent as Sinon.SinonStub).calledWith(
1033
+ 'intent-2',
1034
+ ),
1035
+ ).to.be.true;
1036
+ });
1037
+
1038
+ it('should assign intentId from createRebalanceIntent to route before calling rebalancer', async () => {
1039
+ const { rebalancer, triggerCycle } = await setupDaemonTest(sandbox, {
1040
+ intentIds: ['generated-intent-id'],
1041
+ rebalanceResults: [
1042
+ {
1043
+ route: {
1044
+ origin: 'ethereum',
1045
+ destination: 'arbitrum',
1046
+ amount: 1000n,
1047
+ intentId: 'generated-intent-id',
1048
+ bridge: TEST_ADDRESSES.bridge,
1049
+ },
1050
+ success: true,
1051
+ messageId:
1052
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
1053
+ },
1054
+ ],
1055
+ strategyRoutes: [
1056
+ {
1057
+ origin: 'ethereum',
1058
+ destination: 'arbitrum',
1059
+ amount: 1000n,
1060
+ bridge: TEST_ADDRESSES.bridge,
1061
+ },
1062
+ ],
1063
+ });
1064
+
1065
+ await triggerCycle();
1066
+
1067
+ // Verify rebalancer.rebalance was called with routes that have intentId
1068
+ expect(rebalancer.rebalance.calledOnce).to.be.true;
1069
+ const routesPassedToRebalancer = rebalancer.rebalance.firstCall.args[0];
1070
+ expect(routesPassedToRebalancer).to.have.lengthOf(1);
1071
+ expect(routesPassedToRebalancer[0].intentId).to.equal(
1072
+ 'generated-intent-id',
1073
+ );
1074
+ });
1075
+ });
1076
+
1077
+ describe('initialization', () => {
1078
+ it('should initialize only once', async () => {
1079
+ const contextFactory = createMockContextFactory();
1080
+ const createStub = sandbox
1081
+ .stub(RebalancerContextFactory, 'create')
1082
+ .resolves(contextFactory);
1083
+
1084
+ const config: RebalancerServiceConfig = {
1085
+ mode: 'manual',
1086
+ logger: testLogger,
1087
+ };
1088
+
1089
+ const service = new RebalancerService(
1090
+ createMockMultiProvider(),
1091
+ undefined,
1092
+ {} as any,
1093
+ createMockRebalancerConfig(),
1094
+ config,
1095
+ );
1096
+
1097
+ await service.executeManual({
1098
+ origin: 'ethereum',
1099
+ destination: 'arbitrum',
1100
+ amount: '100',
1101
+ });
1102
+
1103
+ await service.executeManual({
1104
+ origin: 'ethereum',
1105
+ destination: 'arbitrum',
1106
+ amount: '200',
1107
+ });
1108
+
1109
+ expect(createStub.calledOnce).to.be.true;
1110
+ });
1111
+
1112
+ it('should create metrics when withMetrics is enabled', async () => {
1113
+ const metrics = {} as Metrics;
1114
+ const contextFactory = createMockContextFactory({ metrics });
1115
+ const createMetricsSpy = Sinon.spy(contextFactory, 'createMetrics');
1116
+
1117
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
1118
+
1119
+ const config: RebalancerServiceConfig = {
1120
+ mode: 'manual',
1121
+ withMetrics: true,
1122
+ coingeckoApiKey: 'test-key',
1123
+ logger: testLogger,
1124
+ };
1125
+
1126
+ const service = new RebalancerService(
1127
+ createMockMultiProvider(),
1128
+ undefined,
1129
+ {} as any,
1130
+ createMockRebalancerConfig(),
1131
+ config,
1132
+ );
1133
+
1134
+ await service.executeManual({
1135
+ origin: 'ethereum',
1136
+ destination: 'arbitrum',
1137
+ amount: '100',
1138
+ });
1139
+
1140
+ expect(createMetricsSpy.calledOnce).to.be.true;
1141
+ expect(createMetricsSpy.firstCall.args[0]).to.equal('test-key');
1142
+ });
1143
+ });
1144
+ });