@hyperlane-xyz/rebalancer 27.2.11 → 27.2.13

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 (91) hide show
  1. package/dist/core/InventoryRebalancer.d.ts +11 -19
  2. package/dist/core/InventoryRebalancer.d.ts.map +1 -1
  3. package/dist/core/InventoryRebalancer.js +336 -268
  4. package/dist/core/InventoryRebalancer.js.map +1 -1
  5. package/dist/core/InventoryRebalancer.test.js +397 -23
  6. package/dist/core/InventoryRebalancer.test.js.map +1 -1
  7. package/dist/core/Rebalancer.d.ts.map +1 -1
  8. package/dist/core/Rebalancer.js +12 -6
  9. package/dist/core/Rebalancer.js.map +1 -1
  10. package/dist/core/Rebalancer.test.js +51 -0
  11. package/dist/core/Rebalancer.test.js.map +1 -1
  12. package/dist/core/RebalancerOrchestrator.test.js +0 -1
  13. package/dist/core/RebalancerOrchestrator.test.js.map +1 -1
  14. package/dist/core/RebalancerService.d.ts +2 -3
  15. package/dist/core/RebalancerService.d.ts.map +1 -1
  16. package/dist/core/RebalancerService.js +3 -2
  17. package/dist/core/RebalancerService.js.map +1 -1
  18. package/dist/core/RebalancerService.test.js +24 -0
  19. package/dist/core/RebalancerService.test.js.map +1 -1
  20. package/dist/e2e/harness/TestHelpers.js +1 -2
  21. package/dist/e2e/harness/TestHelpers.js.map +1 -1
  22. package/dist/factories/RebalancerContextFactory.d.ts +4 -5
  23. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  24. package/dist/factories/RebalancerContextFactory.js +12 -7
  25. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  26. package/dist/factories/RebalancerContextFactory.test.js +99 -2
  27. package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
  28. package/dist/interfaces/IRebalancer.d.ts +4 -2
  29. package/dist/interfaces/IRebalancer.d.ts.map +1 -1
  30. package/dist/metrics/scripts/metrics.d.ts +1 -1
  31. package/dist/monitor/Monitor.d.ts.map +1 -1
  32. package/dist/monitor/Monitor.js +14 -6
  33. package/dist/monitor/Monitor.js.map +1 -1
  34. package/dist/strategy/BaseStrategy.d.ts.map +1 -1
  35. package/dist/strategy/BaseStrategy.js +13 -11
  36. package/dist/strategy/BaseStrategy.js.map +1 -1
  37. package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
  38. package/dist/strategy/CollateralDeficitStrategy.js +2 -2
  39. package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
  40. package/dist/strategy/MinAmountStrategy.d.ts +1 -0
  41. package/dist/strategy/MinAmountStrategy.d.ts.map +1 -1
  42. package/dist/strategy/MinAmountStrategy.js +12 -8
  43. package/dist/strategy/MinAmountStrategy.js.map +1 -1
  44. package/dist/strategy/MinAmountStrategy.test.js +189 -2
  45. package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
  46. package/dist/test/helpers.d.ts +11 -3
  47. package/dist/test/helpers.d.ts.map +1 -1
  48. package/dist/test/helpers.js +9 -11
  49. package/dist/test/helpers.js.map +1 -1
  50. package/dist/test/lifiMocks.d.ts.map +1 -1
  51. package/dist/test/lifiMocks.js +5 -2
  52. package/dist/test/lifiMocks.js.map +1 -1
  53. package/dist/tracking/ActionTracker.d.ts.map +1 -1
  54. package/dist/tracking/ActionTracker.js +2 -1
  55. package/dist/tracking/ActionTracker.js.map +1 -1
  56. package/dist/tracking/ActionTracker.test.js +39 -0
  57. package/dist/tracking/ActionTracker.test.js.map +1 -1
  58. package/dist/utils/balanceUtils.d.ts +7 -1
  59. package/dist/utils/balanceUtils.d.ts.map +1 -1
  60. package/dist/utils/balanceUtils.js +39 -1
  61. package/dist/utils/balanceUtils.js.map +1 -1
  62. package/dist/utils/balanceUtils.test.js +55 -1
  63. package/dist/utils/balanceUtils.test.js.map +1 -1
  64. package/dist/utils/blockTag.d.ts +3 -3
  65. package/dist/utils/blockTag.d.ts.map +1 -1
  66. package/dist/utils/blockTag.js +1 -1
  67. package/dist/utils/blockTag.js.map +1 -1
  68. package/package.json +7 -7
  69. package/src/core/InventoryRebalancer.test.ts +503 -38
  70. package/src/core/InventoryRebalancer.ts +483 -350
  71. package/src/core/Rebalancer.test.ts +84 -0
  72. package/src/core/Rebalancer.ts +22 -6
  73. package/src/core/RebalancerOrchestrator.test.ts +0 -1
  74. package/src/core/RebalancerService.test.ts +35 -0
  75. package/src/core/RebalancerService.ts +9 -5
  76. package/src/e2e/harness/TestHelpers.ts +3 -3
  77. package/src/factories/RebalancerContextFactory.test.ts +143 -6
  78. package/src/factories/RebalancerContextFactory.ts +29 -17
  79. package/src/interfaces/IRebalancer.ts +4 -1
  80. package/src/monitor/Monitor.ts +19 -6
  81. package/src/strategy/BaseStrategy.ts +18 -15
  82. package/src/strategy/CollateralDeficitStrategy.ts +4 -3
  83. package/src/strategy/MinAmountStrategy.test.ts +238 -2
  84. package/src/strategy/MinAmountStrategy.ts +29 -17
  85. package/src/test/helpers.ts +13 -12
  86. package/src/test/lifiMocks.ts +5 -2
  87. package/src/tracking/ActionTracker.test.ts +47 -0
  88. package/src/tracking/ActionTracker.ts +2 -1
  89. package/src/utils/balanceUtils.test.ts +87 -1
  90. package/src/utils/balanceUtils.ts +73 -2
  91. package/src/utils/blockTag.ts +9 -4
@@ -229,6 +229,53 @@ describe('Rebalancer', () => {
229
229
  expect(results[0].success).to.be.false;
230
230
  });
231
231
 
232
+ it('should log scaled route amounts using origin local units', async () => {
233
+ const ctx = createRebalancerTestContext(['ethereum']);
234
+ ctx.tokensByChainName.ethereum.scale = {
235
+ numerator: 1,
236
+ denominator: 1_000_000_000_000,
237
+ };
238
+
239
+ const logger = {
240
+ child: Sinon.stub(),
241
+ info: Sinon.stub(),
242
+ warn: Sinon.stub(),
243
+ error: Sinon.stub(),
244
+ };
245
+ logger.child.returns(logger);
246
+
247
+ const rebalancer = new Rebalancer(
248
+ ctx.warpCore,
249
+ ctx.chainMetadata,
250
+ ctx.tokensByChainName,
251
+ ctx.multiProvider as any,
252
+ createMockActionTracker(),
253
+ logger as any,
254
+ );
255
+
256
+ const route = buildTestMovableCollateralRoute({
257
+ origin: 'ethereum',
258
+ destination: 'arbitrum',
259
+ amount: 1_000_000n,
260
+ });
261
+ const results = await rebalancer.rebalance([route]);
262
+
263
+ expect(results).to.have.lengthOf(1);
264
+ expect(results[0].success).to.be.false;
265
+ const validationErrorCall = logger.error
266
+ .getCalls()
267
+ .find(
268
+ (call) =>
269
+ call.args[1] ===
270
+ 'Route validation failed: destination token not found.',
271
+ );
272
+ expect(validationErrorCall).to.not.be.undefined;
273
+ expect(validationErrorCall!.args[0].amount).to.equal(1);
274
+ expect(validationErrorCall!.args[1]).to.equal(
275
+ 'Route validation failed: destination token not found.',
276
+ );
277
+ });
278
+
232
279
  it('should fail when signer is not a rebalancer', async () => {
233
280
  const ctx = createRebalancerTestContext(['ethereum', 'arbitrum'], {
234
281
  ethereum: { isRebalancer: false },
@@ -337,6 +384,43 @@ describe('Rebalancer', () => {
337
384
  expect(results).to.have.lengthOf(1);
338
385
  expect(results[0].success).to.be.false;
339
386
  });
387
+
388
+ it('should denormalize canonical route amounts before quote and populate calls', async () => {
389
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum']);
390
+ ctx.tokensByChainName.ethereum.scale = {
391
+ numerator: 1,
392
+ denominator: 1_000_000_000_000,
393
+ };
394
+
395
+ sandbox.stub(HyperlaneCore, 'getDispatchedMessages').returns([
396
+ {
397
+ id: '0x1111111111111111111111111111111111111111111111111111111111111111',
398
+ } as any,
399
+ ]);
400
+
401
+ const rebalancer = new Rebalancer(
402
+ ctx.warpCore,
403
+ ctx.chainMetadata,
404
+ ctx.tokensByChainName,
405
+ ctx.multiProvider as any,
406
+ createMockActionTracker(),
407
+ testLogger,
408
+ );
409
+
410
+ await rebalancer.rebalance([
411
+ buildTestMovableCollateralRoute({
412
+ amount: 1_000_000n,
413
+ }),
414
+ ]);
415
+
416
+ expect(ctx.adapters.ethereum.getRebalanceQuotes.calledOnce).to.be.true;
417
+ expect(
418
+ ctx.adapters.ethereum.getRebalanceQuotes.firstCall.args[3],
419
+ ).to.equal(1_000_000_000_000_000_000n);
420
+ expect(
421
+ ctx.adapters.ethereum.populateRebalanceTx.firstCall.args[1],
422
+ ).to.equal(1_000_000_000_000_000_000n);
423
+ });
340
424
  });
341
425
 
342
426
  describe('executeTransactions()', () => {
@@ -25,10 +25,16 @@ import { MovableCollateralRoute } from '../interfaces/IStrategy.js';
25
25
  import { type Metrics } from '../metrics/Metrics.js';
26
26
  import type { IActionTracker } from '../tracking/IActionTracker.js';
27
27
  import type { RebalanceIntent } from '../tracking/types.js';
28
+ import {
29
+ denormalizeToLocal,
30
+ normalizeToCanonical,
31
+ } from '../utils/balanceUtils.js';
28
32
 
29
33
  // Internal types with intentId for tracking
30
34
  type InternalExecutionResult = MovableCollateralExecutionResult & {
31
35
  intentId: string;
36
+ canonicalAmount?: bigint;
37
+ localAmount?: bigint;
32
38
  };
33
39
 
34
40
  type InternalRoute = MovableCollateralRoute & { intentId: string };
@@ -104,7 +110,10 @@ export class Rebalancer implements IMovableCollateralRebalancer {
104
110
  if (token) {
105
111
  this.metrics.recordRebalanceAmount(
106
112
  result.route,
107
- token.amount(result.route.amount),
113
+ token.amount(
114
+ result.localAmount ??
115
+ denormalizeToLocal(result.route.amount, token),
116
+ ),
108
117
  );
109
118
  }
110
119
  }
@@ -150,7 +159,7 @@ export class Rebalancer implements IMovableCollateralRebalancer {
150
159
  intentId,
151
160
  origin: this.multiProvider.getDomainId(result.route.origin),
152
161
  destination: this.multiProvider.getDomainId(result.route.destination),
153
- amount: result.route.amount,
162
+ amount: result.canonicalAmount ?? result.route.amount,
154
163
  type: 'rebalance_message',
155
164
  messageId: result.messageId,
156
165
  txHash: result.txHash,
@@ -264,8 +273,9 @@ export class Rebalancer implements IMovableCollateralRebalancer {
264
273
  const originToken = this.tokensByChainName[origin];
265
274
  const destinationToken = this.tokensByChainName[destination];
266
275
  const destinationChainMeta = this.chainMetadata[destination];
276
+ const localAmount = denormalizeToLocal(amount, originToken);
267
277
 
268
- const originTokenAmount = originToken.amount(amount);
278
+ const originTokenAmount = originToken.amount(localAmount);
269
279
  const decimalFormattedAmount =
270
280
  originTokenAmount.getDecimalFormattedAmount();
271
281
  const originHypAdapter = originToken.getHypAdapter(
@@ -281,7 +291,7 @@ export class Rebalancer implements IMovableCollateralRebalancer {
281
291
  bridge,
282
292
  destinationChainMeta.domainId,
283
293
  destinationToken.addressOrDenom,
284
- amount,
294
+ localAmount,
285
295
  );
286
296
  } catch (error) {
287
297
  this.logger.error(
@@ -302,7 +312,7 @@ export class Rebalancer implements IMovableCollateralRebalancer {
302
312
  try {
303
313
  populatedTx = await originHypAdapter.populateRebalanceTx(
304
314
  destinationChainMeta.domainId,
305
- amount,
315
+ localAmount,
306
316
  bridge,
307
317
  quotes,
308
318
  );
@@ -337,7 +347,8 @@ export class Rebalancer implements IMovableCollateralRebalancer {
337
347
  return false;
338
348
  }
339
349
 
340
- const originTokenAmount = originToken.amount(amount);
350
+ const localAmount = denormalizeToLocal(amount, originToken);
351
+ const originTokenAmount = originToken.amount(localAmount);
341
352
  const decimalFormattedAmount =
342
353
  originTokenAmount.getDecimalFormattedAmount();
343
354
 
@@ -676,6 +687,11 @@ export class Rebalancer implements IMovableCollateralRebalancer {
676
687
  success: true,
677
688
  messageId: dispatchedMessages[0].id,
678
689
  txHash: receipt.transactionHash,
690
+ canonicalAmount: normalizeToCanonical(
691
+ transaction.originTokenAmount.amount,
692
+ transaction.originTokenAmount.token,
693
+ ),
694
+ localAmount: transaction.originTokenAmount.amount,
679
695
  };
680
696
  }
681
697
 
@@ -646,7 +646,6 @@ describe('RebalancerOrchestrator', () => {
646
646
  expect(inventoryRebalancer.rebalance.calledWith([])).to.be.false;
647
647
  });
648
648
 
649
- // eslint-disable-next-line jest/expect-expect
650
649
  it('should NOT call inventoryRebalancer.rebalance([]) when inventoryRebalancer is not in rebalancers', async () => {
651
650
  const strategy = createMockStrategy();
652
651
  strategy.getRebalancingRoutes.returns([]);
@@ -366,6 +366,41 @@ describe('RebalancerService', () => {
366
366
  expect(calledRoutes[0].destination).to.equal('arbitrum');
367
367
  });
368
368
 
369
+ it('should normalize manual amount to canonical units when token has scale', async () => {
370
+ const rebalancer = createMockRebalancer();
371
+ const warpCore = {
372
+ tokens: [
373
+ {
374
+ ...createMockToken('ethereum'),
375
+ decimals: 18,
376
+ scale: { numerator: 1n, denominator: 1_000_000_000_000n },
377
+ },
378
+ createMockToken('arbitrum'),
379
+ ],
380
+ multiProvider: createMockMultiProvider(),
381
+ } as unknown as WarpCore;
382
+
383
+ const contextFactory = createMockContextFactory({ rebalancer, warpCore });
384
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
385
+
386
+ const service = new RebalancerService(
387
+ createMockMultiProvider(),
388
+ undefined,
389
+ {} as any,
390
+ createMockRebalancerConfig(),
391
+ { mode: 'manual', logger: testLogger },
392
+ );
393
+
394
+ await service.executeManual({
395
+ origin: 'ethereum',
396
+ destination: 'arbitrum',
397
+ amount: '1',
398
+ });
399
+
400
+ const calledRoutes = rebalancer.rebalance.firstCall.args[0];
401
+ expect(calledRoutes[0].amount).to.equal(1_000_000n);
402
+ });
403
+
369
404
  it('should throw when origin token not found', async () => {
370
405
  const warpCore = {
371
406
  tokens: [createMockToken('arbitrum')],
@@ -1,9 +1,12 @@
1
1
  import { Logger } from 'pino';
2
2
 
3
3
  import { IRegistry } from '@hyperlane-xyz/registry';
4
- import { type MultiProvider, Token } from '@hyperlane-xyz/sdk';
5
- import type { MultiProviderAdapter } from '@hyperlane-xyz/sdk/providers/MultiProviderAdapter';
6
- import { ProtocolType, assert, toWei } from '@hyperlane-xyz/utils';
4
+ import {
5
+ type MultiProtocolProvider,
6
+ type MultiProvider,
7
+ Token,
8
+ } from '@hyperlane-xyz/sdk';
9
+ import { ProtocolType, assert } from '@hyperlane-xyz/utils';
7
10
 
8
11
  import { RebalancerConfig } from '../config/RebalancerConfig.js';
9
12
  import {
@@ -27,6 +30,7 @@ import { Metrics } from '../metrics/Metrics.js';
27
30
  import { type InventoryMonitorConfig, Monitor } from '../monitor/Monitor.js';
28
31
  import type { IActionTracker } from '../tracking/IActionTracker.js';
29
32
  import { InflightContextAdapter } from '../tracking/InflightContextAdapter.js';
33
+ import { normalizeConfiguredAmount } from '../utils/balanceUtils.js';
30
34
 
31
35
  import type { RebalancerOrchestrator } from './RebalancerOrchestrator.js';
32
36
 
@@ -121,7 +125,7 @@ export class RebalancerService {
121
125
  private orchestrator?: RebalancerOrchestrator;
122
126
  constructor(
123
127
  private readonly multiProvider: MultiProvider,
124
- private readonly multiProtocolProvider: MultiProviderAdapter | undefined,
128
+ private readonly multiProtocolProvider: MultiProtocolProvider | undefined,
125
129
  private readonly registry: IRegistry,
126
130
  private readonly rebalancerConfig: RebalancerConfig,
127
131
  private readonly config: RebalancerServiceConfig,
@@ -293,7 +297,7 @@ export class RebalancerService {
293
297
  const manualRoute: MovableCollateralRoute & { intentId: string } = {
294
298
  origin,
295
299
  destination,
296
- amount: BigInt(toWei(amount, originToken.decimals)),
300
+ amount: normalizeConfiguredAmount(amount, originToken),
297
301
  executionType: 'movableCollateral',
298
302
  bridge,
299
303
  intentId: `manual-${Date.now()}`,
@@ -25,9 +25,9 @@ export async function getFirstMonitorEvent(
25
25
  return new Promise((resolve, reject) => {
26
26
  let settled = false;
27
27
 
28
- async function finalize(
29
- cb: (v: any) => void, // eslint-disable-line @typescript-eslint/no-explicit-any -- accepts both resolve and reject
30
- value: unknown,
28
+ async function finalize<T>(
29
+ cb: (v: T) => void,
30
+ value: T,
31
31
  timer: ReturnType<typeof setTimeout>,
32
32
  ) {
33
33
  if (settled) return;
@@ -247,6 +247,78 @@ describe('RebalancerContextFactory', () => {
247
247
  expect(providerChains).to.include('arbitrum');
248
248
  });
249
249
 
250
+ it('should fail fast when bridgeMinAcceptedAmount is configured for a chain without a token', async () => {
251
+ const { multiProvider } = createMockMultiProvider([
252
+ { name: 'ethereum', protocol: ProtocolType.Ethereum },
253
+ { name: 'arbitrum', protocol: ProtocolType.Ethereum },
254
+ ]);
255
+
256
+ const config = createMockConfig();
257
+ config.strategyConfig[0].chains.arbitrum.bridgeMinAcceptedAmount = 1;
258
+
259
+ let error: Error | undefined;
260
+ try {
261
+ const factory = await createFactory(config, multiProvider, {
262
+ tokens: [
263
+ createToken(
264
+ 'ethereum',
265
+ TEST_ADDRESSES.ethereum,
266
+ TokenStandard.EvmHypCollateral,
267
+ ),
268
+ ],
269
+ } as WarpCoreConfig);
270
+ await factory.createStrategy();
271
+ } catch (caught) {
272
+ error = caught as Error;
273
+ }
274
+
275
+ expect(error?.message).to.equal(
276
+ 'No token found for configured strategy chain arbitrum in warp route USDC/paradex',
277
+ );
278
+ });
279
+
280
+ it('should fail fast when bridged supply is unavailable during initial collateral calculation', async () => {
281
+ const { multiProvider } = createMockMultiProvider([
282
+ { name: 'ethereum', protocol: ProtocolType.Ethereum },
283
+ { name: 'arbitrum', protocol: ProtocolType.Ethereum },
284
+ ]);
285
+
286
+ const factory = await createFactory(createMockConfig(), multiProvider, {
287
+ tokens: [
288
+ createToken(
289
+ 'ethereum',
290
+ TEST_ADDRESSES.ethereum,
291
+ TokenStandard.EvmHypCollateral,
292
+ ),
293
+ createToken(
294
+ 'arbitrum',
295
+ TEST_ADDRESSES.arbitrum,
296
+ TokenStandard.EvmHypSynthetic,
297
+ ),
298
+ ],
299
+ });
300
+
301
+ const collateralToken = factory
302
+ .getWarpCore()
303
+ .tokens.find((token) => token.chainName === 'ethereum');
304
+ assert(collateralToken, 'Expected ethereum collateral token in test');
305
+
306
+ sandbox.stub(collateralToken, 'getHypAdapter').returns({
307
+ getBridgedSupply: sandbox.stub().resolves(undefined),
308
+ } as any);
309
+
310
+ let error: Error | undefined;
311
+ try {
312
+ await factory.createStrategy();
313
+ } catch (caught) {
314
+ error = caught as Error;
315
+ }
316
+
317
+ expect(error?.message).to.equal(
318
+ 'Missing bridged supply for ethereum while computing initial total collateral for warp route USDC/paradex',
319
+ );
320
+ });
321
+
250
322
  it('should fail early when inventory override origin protocol signer key is missing', async () => {
251
323
  const sealevelChain = 'solana';
252
324
  const evmChain = 'ethereum';
@@ -310,13 +382,70 @@ describe('RebalancerContextFactory', () => {
310
382
  : ProtocolType.Ethereum,
311
383
  }));
312
384
 
313
- await expect(
314
- (factory as any).createInventoryRebalancerAndConfig({} as any, {}),
315
- ).to.be.rejectedWith(
385
+ let error: Error | undefined;
386
+ try {
387
+ await (factory as any).createInventoryRebalancerAndConfig(
388
+ {} as any,
389
+ {},
390
+ );
391
+ } catch (caught) {
392
+ error = caught as Error;
393
+ }
394
+
395
+ expect(error?.message).to.contain(
316
396
  `Missing inventory signer key for protocol ${ProtocolType.Sealevel}`,
317
397
  );
318
398
  });
319
399
 
400
+ it('should fail early when an inventory-relevant chain has no token', async () => {
401
+ const { multiProvider } = createMockMultiProvider([
402
+ { name: 'ethereum', protocol: ProtocolType.Ethereum },
403
+ { name: 'arbitrum', protocol: ProtocolType.Ethereum },
404
+ ]);
405
+
406
+ const config = {
407
+ ...createMockConfig(),
408
+ inventorySigners: {
409
+ [ProtocolType.Ethereum]: {
410
+ address: TEST_ADDRESSES.ethereum,
411
+ key: '0xabc123',
412
+ },
413
+ },
414
+ } as RebalancerConfig;
415
+ config.strategyConfig[0].chains.arbitrum.executionType =
416
+ ExecutionType.Inventory;
417
+
418
+ const factory = await createFactory(config, multiProvider, {
419
+ tokens: [
420
+ createToken(
421
+ 'ethereum',
422
+ TEST_ADDRESSES.ethereum,
423
+ TokenStandard.EvmHypCollateral,
424
+ ),
425
+ ],
426
+ });
427
+
428
+ const getChainMetadataStub = factory.getWarpCore().multiProvider
429
+ .getChainMetadata as Sinon.SinonStub;
430
+ getChainMetadataStub.callsFake(() => ({
431
+ protocol: ProtocolType.Ethereum,
432
+ }));
433
+
434
+ let error: Error | undefined;
435
+ try {
436
+ await (factory as any).createInventoryRebalancerAndConfig(
437
+ {} as any,
438
+ {},
439
+ );
440
+ } catch (caught) {
441
+ error = caught as Error;
442
+ }
443
+
444
+ expect(error?.message).to.equal(
445
+ 'No token found for inventory-relevant chain arbitrum in warp route USDC/paradex',
446
+ );
447
+ });
448
+
320
449
  it('should fail early when inventory chain uses unsupported protocol', async () => {
321
450
  const cosmosChain = 'cosmoshub';
322
451
  const evmChain = 'ethereum';
@@ -384,9 +513,17 @@ describe('RebalancerContextFactory', () => {
384
513
  : ProtocolType.Ethereum,
385
514
  }));
386
515
 
387
- await expect(
388
- (factory as any).createInventoryRebalancerAndConfig({} as any, {}),
389
- ).to.be.rejectedWith(
516
+ let error: Error | undefined;
517
+ try {
518
+ await (factory as any).createInventoryRebalancerAndConfig(
519
+ {} as any,
520
+ {},
521
+ );
522
+ } catch (caught) {
523
+ error = caught as Error;
524
+ }
525
+
526
+ expect(error?.message).to.contain(
390
527
  `Inventory rebalancing does not support protocol '${ProtocolType.Cosmos}'`,
391
528
  );
392
529
  });
@@ -11,14 +11,7 @@ import {
11
11
  WarpCore,
12
12
  type WarpCoreConfig,
13
13
  } from '@hyperlane-xyz/sdk';
14
- import type { MultiProviderAdapter } from '@hyperlane-xyz/sdk/providers/MultiProviderAdapter';
15
- import {
16
- Address,
17
- assert,
18
- ProtocolType,
19
- objMap,
20
- toWei,
21
- } from '@hyperlane-xyz/utils';
14
+ import { Address, assert, ProtocolType, objMap } from '@hyperlane-xyz/utils';
22
15
 
23
16
  import { LiFiBridge } from '../bridges/LiFiBridge.js';
24
17
  import { type RebalancerConfig } from '../config/RebalancerConfig.js';
@@ -64,6 +57,10 @@ import {
64
57
  ExplorerClient,
65
58
  type IExplorerClient,
66
59
  } from '../utils/ExplorerClient.js';
60
+ import {
61
+ normalizeConfiguredAmount,
62
+ normalizeToCanonical,
63
+ } from '../utils/balanceUtils.js';
67
64
  import { isCollateralizedTokenEligibleForRebalancing } from '../utils/tokenUtils.js';
68
65
 
69
66
  const DEFAULT_EXPLORER_URL =
@@ -75,7 +72,7 @@ export class RebalancerContextFactory {
75
72
  * @param warpCore - An instance of `WarpCore` configured for the specified `warpRouteId`.
76
73
  * @param tokensByChainName - A map of chain->token to ease the lookup of token by chain
77
74
  * @param multiProvider - MultiProvider instance (for movable collateral operations)
78
- * @param multiProtocolProvider - MultiProviderAdapter instance (with mailbox metadata)
75
+ * @param multiProtocolProvider - MultiProtocolProvider instance (with mailbox metadata)
79
76
  * @param registry - IRegistry instance
80
77
  * @param logger - Logger instance
81
78
  */
@@ -84,7 +81,7 @@ export class RebalancerContextFactory {
84
81
  private readonly warpCore: WarpCore,
85
82
  private readonly tokensByChainName: ChainMap<Token>,
86
83
  private readonly multiProvider: MultiProvider,
87
- private readonly multiProtocolProvider: MultiProviderAdapter,
84
+ private readonly multiProtocolProvider: MultiProtocolProvider,
88
85
  private readonly registry: IRegistry,
89
86
  private readonly logger: Logger,
90
87
  private readonly inventorySignerKeysByProtocol?: Partial<
@@ -95,14 +92,14 @@ export class RebalancerContextFactory {
95
92
  /**
96
93
  * @param config - The rebalancer config
97
94
  * @param multiProvider - MultiProvider instance (for movable collateral operations)
98
- * @param multiProtocolProvider - MultiProviderAdapter instance (optional, created from multiProvider if not provided)
95
+ * @param multiProtocolProvider - MultiProtocolProvider instance (optional, created from multiProvider if not provided)
99
96
  * @param registry - IRegistry instance
100
97
  * @param logger - Logger instance
101
98
  */
102
99
  public static async create(
103
100
  config: RebalancerConfig,
104
101
  multiProvider: MultiProvider,
105
- multiProtocolProvider: MultiProviderAdapter | undefined,
102
+ multiProtocolProvider: MultiProtocolProvider | undefined,
106
103
  registry: IRegistry,
107
104
  logger: Logger,
108
105
  inventorySignerKeysByProtocol?: Partial<Record<ProtocolType, string>>,
@@ -147,7 +144,7 @@ export class RebalancerContextFactory {
147
144
  multiProvider.getProvider(chain);
148
145
  }
149
146
 
150
- // Create MultiProviderAdapter (convert from MultiProvider if not provided)
147
+ // Create MultiProtocolProvider (convert from MultiProvider if not provided)
151
148
  const mpp =
152
149
  multiProtocolProvider ??
153
150
  MultiProtocolProvider.fromMultiProvider(multiProvider);
@@ -253,9 +250,13 @@ export class RebalancerContextFactory {
253
250
  );
254
251
  if (chainConfig?.bridgeMinAcceptedAmount) {
255
252
  const token = this.tokensByChainName[chainName];
256
- const decimals = token?.decimals ?? 18;
257
- minAmountsByChain[chainName] = BigInt(
258
- toWei(chainConfig.bridgeMinAcceptedAmount, decimals),
253
+ assert(
254
+ token,
255
+ `No token found for configured strategy chain ${chainName} in warp route ${this.config.warpRouteId}`,
256
+ );
257
+ minAmountsByChain[chainName] = normalizeConfiguredAmount(
258
+ chainConfig.bridgeMinAcceptedAmount,
259
+ token,
259
260
  );
260
261
  }
261
262
  }
@@ -460,6 +461,13 @@ export class RebalancerContextFactory {
460
461
  return null;
461
462
  }
462
463
 
464
+ for (const chain of allRelevantChains) {
465
+ assert(
466
+ this.tokensByChainName[chain],
467
+ `No token found for inventory-relevant chain ${chain} in warp route ${this.config.warpRouteId}`,
468
+ );
469
+ }
470
+
463
471
  const requiredProtocols = new Set(
464
472
  allRelevantChains.map((chain) => {
465
473
  const metadata = this.warpCore.multiProvider.getChainMetadata(chain);
@@ -762,7 +770,11 @@ export class RebalancerContextFactory {
762
770
  ) {
763
771
  const adapter = token.getHypAdapter(this.warpCore.multiProvider);
764
772
  const bridgedSupply = await adapter.getBridgedSupply();
765
- initialTotalCollateral += bridgedSupply ?? 0n;
773
+ assert(
774
+ bridgedSupply !== undefined,
775
+ `Missing bridged supply for ${token.chainName} while computing initial total collateral for warp route ${this.config.warpRouteId}`,
776
+ );
777
+ initialTotalCollateral += normalizeToCanonical(bridgedSupply, token);
766
778
  }
767
779
  }),
768
780
  );
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type EvmMovableCollateralAdapter,
3
+ type IToken,
3
4
  type TokenAmount,
4
5
  } from '@hyperlane-xyz/sdk';
5
6
 
@@ -48,10 +49,12 @@ export type IInventoryRebalancer = IRebalancer<
48
49
  InventoryExecutionResult
49
50
  >;
50
51
 
52
+ type PreparedOriginTokenAmount = TokenAmount<IToken>;
53
+
51
54
  export type PreparedTransaction = {
52
55
  populatedTx: Awaited<
53
56
  ReturnType<EvmMovableCollateralAdapter['populateRebalanceTx']>
54
57
  >;
55
58
  route: MovableCollateralRoute & { intentId: string };
56
- originTokenAmount: TokenAmount;
59
+ originTokenAmount: PreparedOriginTokenAmount;
57
60
  };
@@ -6,7 +6,7 @@ import {
6
6
  type Token,
7
7
  type WarpCore,
8
8
  } from '@hyperlane-xyz/sdk';
9
- import { Address, ProtocolType, sleep } from '@hyperlane-xyz/utils';
9
+ import { Address, ProtocolType, fromWei, sleep } from '@hyperlane-xyz/utils';
10
10
 
11
11
  import {
12
12
  type ConfirmedBlockTag,
@@ -140,16 +140,29 @@ export class Monitor implements IMonitor {
140
140
  const inventoryBalances = await this.fetchInventoryBalances();
141
141
  if (Object.keys(inventoryBalances).length > 0) {
142
142
  event.inventoryBalances = inventoryBalances;
143
+ const tokensByChain = new Map(
144
+ this.warpCore.tokens.map((token) => [token.chainName, token]),
145
+ );
146
+ // CAST: inventoryBalances keys come from configured monitor chains,
147
+ // but Object.entries widens them to string.
148
+ const inventoryBalanceEntries = Object.entries(
149
+ inventoryBalances,
150
+ ) as [ChainName, bigint][];
143
151
  this.logger.info(
144
152
  {
145
153
  chainsMonitored: Object.keys(inventoryBalances).length,
146
- balances: Object.entries(inventoryBalances).map(
147
- ([chain, balance]) => ({
154
+ balances: inventoryBalanceEntries.map(([chain, balance]) => {
155
+ const token = tokensByChain.get(chain);
156
+ return {
148
157
  chain,
158
+ tokenSymbol: token?.symbol,
149
159
  balance: balance.toString(),
150
- balanceEth: (Number(balance) / 1e18).toFixed(6),
151
- }),
152
- ),
160
+ balanceFormatted: fromWei(
161
+ balance.toString(),
162
+ token?.decimals,
163
+ ),
164
+ };
165
+ }),
153
166
  },
154
167
  'Inventory balances fetched',
155
168
  );