@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,390 @@
1
+ import { Logger } from 'pino';
2
+
3
+ import { type ChainMap, type ChainName, type Token } from '@hyperlane-xyz/sdk';
4
+ import { toWei } from '@hyperlane-xyz/utils';
5
+
6
+ import {
7
+ type CollateralDeficitStrategyConfig,
8
+ RebalancerStrategyOptions,
9
+ } from '../config/types.js';
10
+ import type {
11
+ InflightContext,
12
+ RawBalances,
13
+ Route,
14
+ StrategyRoute,
15
+ } from '../interfaces/IStrategy.js';
16
+ import { Metrics } from '../metrics/Metrics.js';
17
+ import type { BridgeConfigWithOverride } from '../utils/bridgeUtils.js';
18
+
19
+ import { BaseStrategy, type Delta } from './BaseStrategy.js';
20
+
21
+ /**
22
+ * Strategy that detects collateral deficits (negative effective balances)
23
+ * and proposes JIT rebalances using fast bridges.
24
+ *
25
+ * Logic:
26
+ * 1. Filter pendingRebalances to only those using this strategy's configured bridges
27
+ * 2. Simulate filtered pending rebalances to get projected balances
28
+ * 3. Negative simulated balance = deficit (magnitude + buffer)
29
+ * 4. Positive simulated balance = potential surplus
30
+ */
31
+ export class CollateralDeficitStrategy extends BaseStrategy {
32
+ readonly name = RebalancerStrategyOptions.CollateralDeficit;
33
+ private readonly config: CollateralDeficitStrategyConfig;
34
+ protected readonly logger: Logger;
35
+
36
+ constructor(
37
+ config: CollateralDeficitStrategyConfig,
38
+ tokensByChainName: ChainMap<Token>,
39
+ logger: Logger,
40
+ bridgeConfigs: ChainMap<BridgeConfigWithOverride>,
41
+ metrics?: Metrics,
42
+ ) {
43
+ const chains = Object.keys(config);
44
+ const log = logger.child({ class: CollateralDeficitStrategy.name });
45
+ super(chains, log, bridgeConfigs, metrics, tokensByChainName);
46
+ this.logger = log;
47
+ this.config = config;
48
+ this.logger.info('CollateralDeficitStrategy created');
49
+ }
50
+
51
+ /**
52
+ * Categorizes balances into surpluses and deficits.
53
+ *
54
+ * 1. Filter pendingRebalances/proposedRebalances by configured bridges
55
+ * 2. Simulate those rebalances to get projected balances
56
+ * 3. Negative balance = deficit (magnitude + buffer)
57
+ * 4. Positive balance = potential surplus
58
+ */
59
+ protected getCategorizedBalances(
60
+ rawBalances: RawBalances,
61
+ pendingRebalances?: Route[],
62
+ proposedRebalances?: StrategyRoute[],
63
+ ): {
64
+ surpluses: Delta[];
65
+ deficits: Delta[];
66
+ } {
67
+ // Filter pending rebalances to only those using this strategy's bridges
68
+ const filteredPending = this.filterByConfiguredBridges(pendingRebalances);
69
+ const filteredProposed = this.filterByConfiguredBridges(proposedRebalances);
70
+
71
+ this.logger.debug(
72
+ {
73
+ context: this.constructor.name,
74
+ totalPending: pendingRebalances?.length ?? 0,
75
+ filteredPending: filteredPending.length,
76
+ totalProposed: proposedRebalances?.length ?? 0,
77
+ filteredProposed: filteredProposed.length,
78
+ },
79
+ 'Filtered rebalances by configured bridges',
80
+ );
81
+
82
+ // Step 1: Simulate pending rebalances (in-flight, origin already deducted on-chain)
83
+ let simulatedBalances = this.simulatePendingRebalances(
84
+ rawBalances,
85
+ filteredPending,
86
+ );
87
+
88
+ // Step 2: Simulate proposed rebalances (from earlier strategies, not yet executed)
89
+ simulatedBalances = this.simulateProposedRebalances(
90
+ simulatedBalances,
91
+ filteredProposed,
92
+ );
93
+
94
+ const surpluses: Delta[] = [];
95
+ const deficits: Delta[] = [];
96
+
97
+ for (const chain of this.chains) {
98
+ const balance = simulatedBalances[chain];
99
+ const token = this.getTokenByChainName(chain);
100
+ const bufferWei = BigInt(
101
+ toWei(this.config[chain].buffer, token.decimals),
102
+ );
103
+
104
+ if (balance < 0n) {
105
+ // Negative balance indicates deficit
106
+ const deficitAmount = -balance + bufferWei;
107
+ deficits.push({ chain, amount: deficitAmount });
108
+
109
+ this.logger.debug(
110
+ {
111
+ context: this.constructor.name,
112
+ chain,
113
+ simulatedBalance: balance.toString(),
114
+ buffer: bufferWei.toString(),
115
+ deficitAmount: deficitAmount.toString(),
116
+ },
117
+ 'Detected collateral deficit',
118
+ );
119
+ } else if (balance > 0n) {
120
+ // Positive balance is potential surplus
121
+ surpluses.push({ chain, amount: balance });
122
+ }
123
+ }
124
+
125
+ this.logger.info(
126
+ {
127
+ surpluses: surpluses.map((s) => ({
128
+ chain: s.chain,
129
+ amount: s.amount.toString(),
130
+ })),
131
+ deficits: deficits.map((d) => ({
132
+ chain: d.chain,
133
+ amount: d.amount.toString(),
134
+ })),
135
+ },
136
+ 'Balance categorization',
137
+ );
138
+
139
+ return { surpluses, deficits };
140
+ }
141
+
142
+ /**
143
+ * Override to prefer transfer origins when selecting surplus chains.
144
+ *
145
+ * When a user transfer creates a deficit, the origin chain of that transfer
146
+ * is the natural source of funds (user deposited there). This prevents
147
+ * unnecessarily draining the largest balance (typically ethereum at 70%).
148
+ */
149
+ override getRebalancingRoutes(
150
+ rawBalances: RawBalances,
151
+ inflightContext?: InflightContext,
152
+ ): StrategyRoute[] {
153
+ const pendingRebalances = inflightContext?.pendingRebalances ?? [];
154
+ const pendingTransfers = inflightContext?.pendingTransfers ?? [];
155
+
156
+ this.logger.info(
157
+ {
158
+ strategy: this.name,
159
+ balances: Object.entries(rawBalances).map(([c, b]) => ({
160
+ chain: c,
161
+ balance: b.toString(),
162
+ })),
163
+ pendingRebalances: pendingRebalances.length,
164
+ pendingTransfers: pendingTransfers.length,
165
+ },
166
+ 'Strategy evaluating',
167
+ );
168
+ this.validateRawBalances(rawBalances);
169
+
170
+ const actualBalances = rawBalances;
171
+
172
+ // Step 1: Reserve collateral for pending user transfers
173
+ const effectiveBalances = this.reserveCollateral(
174
+ rawBalances,
175
+ pendingTransfers,
176
+ );
177
+
178
+ // Step 2: Get categorized balances
179
+ const { surpluses, deficits } = this.getCategorizedBalances(
180
+ effectiveBalances,
181
+ pendingRebalances,
182
+ );
183
+
184
+ this.logger.debug(
185
+ { context: this.constructor.name, surpluses },
186
+ 'Surpluses calculated',
187
+ );
188
+ this.logger.debug(
189
+ { context: this.constructor.name, deficits },
190
+ 'Deficits calculated',
191
+ );
192
+
193
+ const totalSurplus = surpluses.reduce((sum, s) => sum + s.amount, 0n);
194
+ const totalDeficit = deficits.reduce((sum, d) => sum + d.amount, 0n);
195
+
196
+ this.logger.debug(
197
+ { context: this.constructor.name, totalSurplus: totalSurplus.toString() },
198
+ 'Total surplus calculated',
199
+ );
200
+ this.logger.debug(
201
+ { context: this.constructor.name, totalDeficit: totalDeficit.toString() },
202
+ 'Total deficit calculated',
203
+ );
204
+
205
+ // Scale deficits if needed
206
+ if (totalSurplus < totalDeficit) {
207
+ this.logger.warn(
208
+ {
209
+ context: this.constructor.name,
210
+ totalSurplus: totalSurplus.toString(),
211
+ totalDeficit: totalDeficit.toString(),
212
+ },
213
+ 'Deficits are greater than surpluses. Scaling deficits',
214
+ );
215
+ this.metrics?.recordRebalancerFailure();
216
+ for (const deficit of deficits) {
217
+ deficit.amount = (deficit.amount * totalSurplus) / totalDeficit;
218
+ }
219
+ this.logger.debug(
220
+ { context: this.constructor.name, deficits },
221
+ 'Scaled deficits',
222
+ );
223
+ }
224
+
225
+ // Build transfer origin map for deficit chains
226
+ const deficitChains = new Set(deficits.map((d) => d.chain));
227
+ const transferOriginMap = this.buildTransferOriginMap(
228
+ pendingTransfers,
229
+ deficitChains,
230
+ );
231
+
232
+ // Sort surpluses with transfer origin preference (KEY CHANGE from base class)
233
+ this.sortSurplusesWithOriginPreference(surpluses, transferOriginMap);
234
+
235
+ // Sort deficits by amount (largest first)
236
+ deficits.sort((a, b) => (a.amount > b.amount ? -1 : 1));
237
+
238
+ const routes: StrategyRoute[] = [];
239
+
240
+ // Match surpluses to deficits
241
+ while (deficits.length > 0 && surpluses.length > 0) {
242
+ const surplus = surpluses[0];
243
+ const deficit = deficits[0];
244
+ const transferAmount =
245
+ surplus.amount > deficit.amount ? deficit.amount : surplus.amount;
246
+
247
+ if (transferAmount > 0n) {
248
+ const bridgeConfig = this.getBridgeConfigForRoute(
249
+ surplus.chain,
250
+ deficit.chain,
251
+ );
252
+ routes.push({
253
+ origin: surplus.chain,
254
+ destination: deficit.chain,
255
+ amount: transferAmount,
256
+ bridge: bridgeConfig.bridge,
257
+ });
258
+ }
259
+
260
+ deficit.amount -= transferAmount;
261
+ surplus.amount -= transferAmount;
262
+
263
+ if (deficit.amount <= 0n) deficits.shift();
264
+ if (surplus.amount <= 0n) surpluses.shift();
265
+ }
266
+
267
+ this.logger.debug(
268
+ { context: this.constructor.name, routes },
269
+ 'Generated routes',
270
+ );
271
+ this.logger.info(
272
+ { context: this.constructor.name, numberOfRoutes: routes.length },
273
+ 'Found rebalancing routes',
274
+ );
275
+
276
+ const filteredRoutes = this.filterRoutes(routes, actualBalances);
277
+
278
+ this.logger.debug(
279
+ {
280
+ context: this.constructor.name,
281
+ filteredRoutesCount: filteredRoutes.length,
282
+ droppedCount: routes.length - filteredRoutes.length,
283
+ },
284
+ 'Filtered rebalancing routes',
285
+ );
286
+
287
+ return filteredRoutes;
288
+ }
289
+
290
+ /**
291
+ * Filter pending rebalances to only those using this strategy's configured bridges.
292
+ * A rebalance matches if:
293
+ * - Its bridge matches the configured bridge (with overrides) for the route, OR
294
+ * - It has no bridge (recovered from Explorer, can't verify - include to be safe)
295
+ */
296
+ private filterByConfiguredBridges(pendingRebalances?: Route[]): Route[] {
297
+ if (!pendingRebalances || pendingRebalances.length === 0) {
298
+ return [];
299
+ }
300
+
301
+ return pendingRebalances.filter((rebalance) => {
302
+ if (!('bridge' in rebalance) || !rebalance.bridge) {
303
+ this.logger.debug(
304
+ { origin: rebalance.origin, destination: rebalance.destination },
305
+ 'Including pending rebalance without bridge (recovered intent)',
306
+ );
307
+ return true;
308
+ }
309
+ const bridgeConfig = this.getBridgeConfigForRoute(
310
+ rebalance.origin,
311
+ rebalance.destination,
312
+ );
313
+ return bridgeConfig?.bridge === rebalance.bridge;
314
+ });
315
+ }
316
+
317
+ /**
318
+ * Build a map from deficit chains to their transfer origin chains.
319
+ * This identifies which surplus chains are "natural" sources for each deficit.
320
+ */
321
+ private buildTransferOriginMap(
322
+ pendingTransfers: Route[],
323
+ deficitChains: Set<ChainName>,
324
+ ): Map<ChainName, Set<ChainName>> {
325
+ const originMap = new Map<ChainName, Set<ChainName>>();
326
+
327
+ for (const transfer of pendingTransfers) {
328
+ // Only track transfers TO deficit chains
329
+ if (deficitChains.has(transfer.destination)) {
330
+ if (!originMap.has(transfer.destination)) {
331
+ originMap.set(transfer.destination, new Set());
332
+ }
333
+ originMap.get(transfer.destination)!.add(transfer.origin);
334
+ }
335
+ }
336
+
337
+ return originMap;
338
+ }
339
+
340
+ /**
341
+ * Sort surpluses to prefer transfer origins over largest balances.
342
+ *
343
+ * Sorting priority:
344
+ * 1. Chains that are origins of transfers TO any deficit chain (preferred)
345
+ * 2. By amount descending (tiebreaker)
346
+ */
347
+ private sortSurplusesWithOriginPreference(
348
+ surpluses: Delta[],
349
+ transferOriginMap: Map<ChainName, Set<ChainName>>,
350
+ ): void {
351
+ // Collect all origin chains across all deficits
352
+ const allOriginChains = new Set<ChainName>();
353
+ for (const origins of transferOriginMap.values()) {
354
+ for (const origin of origins) {
355
+ allOriginChains.add(origin);
356
+ }
357
+ }
358
+
359
+ surpluses.sort((a, b) => {
360
+ const aIsOrigin = allOriginChains.has(a.chain);
361
+ const bIsOrigin = allOriginChains.has(b.chain);
362
+
363
+ // Prefer transfer origins
364
+ if (aIsOrigin && !bIsOrigin) return -1;
365
+ if (!aIsOrigin && bIsOrigin) return 1;
366
+
367
+ // Tiebreaker: larger amount first
368
+ return a.amount > b.amount ? -1 : 1;
369
+ });
370
+
371
+ if (allOriginChains.size > 0) {
372
+ this.logger.debug(
373
+ {
374
+ context: this.constructor.name,
375
+ preferredOrigins: Array.from(allOriginChains),
376
+ sortedSurpluses: surpluses.map((s) => s.chain),
377
+ },
378
+ 'Sorted surpluses with transfer origin preference',
379
+ );
380
+ }
381
+ }
382
+
383
+ protected getTokenByChainName(chainName: string): Token {
384
+ const token = this.tokensByChainName![chainName];
385
+ if (token === undefined) {
386
+ throw new Error(`Token not found for chain ${chainName}`);
387
+ }
388
+ return token;
389
+ }
390
+ }