@hyperlane-xyz/rebalancer 27.2.12 → 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
@@ -1,7 +1,6 @@
1
1
  import { type Logger } from 'pino';
2
2
 
3
3
  import type { ChainMap, ChainName, Token } from '@hyperlane-xyz/sdk';
4
- import { toWei } from '@hyperlane-xyz/utils';
5
4
 
6
5
  import type {
7
6
  IStrategy,
@@ -18,6 +17,7 @@ import {
18
17
  createStrategyRoute,
19
18
  getBridgeConfig,
20
19
  } from '../utils/bridgeUtils.js';
20
+ import { normalizeConfiguredAmount } from '../utils/balanceUtils.js';
21
21
 
22
22
  export type Delta = { chain: ChainName; amount: bigint };
23
23
 
@@ -528,21 +528,24 @@ export abstract class BaseStrategy implements IStrategy {
528
528
  route.origin,
529
529
  route.destination,
530
530
  );
531
- const minAmount = BigInt(
532
- toWei(bridgeConfig.bridgeMinAcceptedAmount, token.decimals),
533
- );
534
- if (route.amount < minAmount) {
535
- this.logger.info(
536
- {
537
- context: this.constructor.name,
538
- origin: route.origin,
539
- destination: route.destination,
540
- amount: route.amount.toString(),
541
- minAmount: minAmount.toString(),
542
- },
543
- 'Dropping route below bridgeMinAcceptedAmount',
531
+ if (bridgeConfig.bridgeMinAcceptedAmount != null) {
532
+ const minAmount = normalizeConfiguredAmount(
533
+ bridgeConfig.bridgeMinAcceptedAmount,
534
+ token,
544
535
  );
545
- return false;
536
+ if (route.amount < minAmount) {
537
+ this.logger.info(
538
+ {
539
+ context: this.constructor.name,
540
+ origin: route.origin,
541
+ destination: route.destination,
542
+ amount: route.amount.toString(),
543
+ minAmount: minAmount.toString(),
544
+ },
545
+ 'Dropping route below bridgeMinAcceptedAmount',
546
+ );
547
+ return false;
548
+ }
546
549
  }
547
550
  }
548
551
  }
@@ -1,7 +1,6 @@
1
1
  import { Logger } from 'pino';
2
2
 
3
3
  import { type ChainMap, type ChainName, type Token } from '@hyperlane-xyz/sdk';
4
- import { toWei } from '@hyperlane-xyz/utils';
5
4
 
6
5
  import {
7
6
  type CollateralDeficitStrategyConfig,
@@ -20,6 +19,7 @@ import {
20
19
  isInventoryConfig,
21
20
  isMovableCollateralConfig,
22
21
  } from '../utils/bridgeUtils.js';
22
+ import { normalizeConfiguredAmount } from '../utils/balanceUtils.js';
23
23
 
24
24
  import { BaseStrategy, type Delta } from './BaseStrategy.js';
25
25
 
@@ -102,8 +102,9 @@ export class CollateralDeficitStrategy extends BaseStrategy {
102
102
  for (const chain of this.chains) {
103
103
  const balance = simulatedBalances[chain];
104
104
  const token = this.getTokenByChainName(chain);
105
- const bufferWei = BigInt(
106
- toWei(this.config[chain].buffer, token.decimals),
105
+ const bufferWei = normalizeConfiguredAmount(
106
+ this.config[chain].buffer,
107
+ token,
107
108
  );
108
109
 
109
110
  if (balance < 0n) {
@@ -10,8 +10,10 @@ import {
10
10
  } from '@hyperlane-xyz/sdk';
11
11
 
12
12
  import { RebalancerMinAmountType } from '../config/types.js';
13
+ import { type MonitorEvent } from '../interfaces/IMonitor.js';
13
14
  import type { RawBalances } from '../interfaces/IStrategy.js';
14
15
  import { extractBridgeConfigs } from '../test/helpers.js';
16
+ import { getRawBalances } from '../utils/balanceUtils.js';
15
17
 
16
18
  import { MinAmountStrategy } from './MinAmountStrategy.js';
17
19
 
@@ -64,7 +66,6 @@ describe('MinAmountStrategy', () => {
64
66
  ).to.throw('At least two chains must be configured');
65
67
  });
66
68
 
67
- // eslint-disable-next-line jest/expect-expect -- testing constructor doesn't throw
68
69
  it('should create a strategy with minAmount and target using absolute values', () => {
69
70
  new MinAmountStrategy(
70
71
  {
@@ -94,7 +95,6 @@ describe('MinAmountStrategy', () => {
94
95
  );
95
96
  });
96
97
 
97
- // eslint-disable-next-line jest/expect-expect -- testing constructor doesn't throw
98
98
  it('should create a strategy with minAmount and target using relative values', () => {
99
99
  new MinAmountStrategy(
100
100
  {
@@ -415,6 +415,146 @@ describe('MinAmountStrategy', () => {
415
415
  ]);
416
416
  });
417
417
 
418
+ it('should normalize absolute thresholds for mixed-decimal routes', () => {
419
+ const mixedTokensByChainName: ChainMap<Token> = {
420
+ [chain1]: new Token({
421
+ ...tokenArgs,
422
+ chainName: chain1,
423
+ decimals: 18,
424
+ scale: { numerator: 1, denominator: 1_000_000_000_000 },
425
+ }),
426
+ [chain2]: new Token({
427
+ ...tokenArgs,
428
+ chainName: chain2,
429
+ decimals: 6,
430
+ }),
431
+ };
432
+ const config = {
433
+ [chain1]: {
434
+ minAmount: {
435
+ min: '100',
436
+ target: '120',
437
+ type: RebalancerMinAmountType.Absolute,
438
+ },
439
+ bridge: AddressZero,
440
+ bridgeLockTime: 1,
441
+ },
442
+ [chain2]: {
443
+ minAmount: {
444
+ min: '100',
445
+ target: '120',
446
+ type: RebalancerMinAmountType.Absolute,
447
+ },
448
+ bridge: AddressZero,
449
+ bridgeLockTime: 1,
450
+ },
451
+ };
452
+
453
+ const strategy = new MinAmountStrategy(
454
+ config,
455
+ mixedTokensByChainName,
456
+ 250_000_000n,
457
+ testLogger,
458
+ extractBridgeConfigs(config),
459
+ );
460
+
461
+ const routes = strategy.getRebalancingRoutes({
462
+ [chain1]: 50_000_000n,
463
+ [chain2]: 200_000_000n,
464
+ });
465
+
466
+ expect(routes).to.deep.equal([
467
+ {
468
+ origin: chain2,
469
+ destination: chain1,
470
+ amount: 70_000_000n,
471
+ bridge: AddressZero,
472
+ executionType: 'movableCollateral',
473
+ },
474
+ ]);
475
+ });
476
+
477
+ it('normalizes mixed-decimal local balances before routing', () => {
478
+ const mixedTokensByChainName: ChainMap<Token> = {
479
+ [chain1]: new Token({
480
+ ...tokenArgs,
481
+ chainName: chain1,
482
+ standard: TokenStandard.EvmHypCollateral,
483
+ decimals: 18,
484
+ scale: { numerator: 1, denominator: 1_000_000_000_000 },
485
+ }),
486
+ [chain2]: new Token({
487
+ ...tokenArgs,
488
+ chainName: chain2,
489
+ standard: TokenStandard.EvmHypCollateral,
490
+ decimals: 6,
491
+ }),
492
+ };
493
+ const config = {
494
+ [chain1]: {
495
+ minAmount: {
496
+ min: '100',
497
+ target: '120',
498
+ type: RebalancerMinAmountType.Absolute,
499
+ },
500
+ bridge: AddressZero,
501
+ bridgeLockTime: 1,
502
+ },
503
+ [chain2]: {
504
+ minAmount: {
505
+ min: '100',
506
+ target: '120',
507
+ type: RebalancerMinAmountType.Absolute,
508
+ },
509
+ bridge: AddressZero,
510
+ bridgeLockTime: 1,
511
+ },
512
+ };
513
+
514
+ const strategy = new MinAmountStrategy(
515
+ config,
516
+ mixedTokensByChainName,
517
+ 250_000_000n,
518
+ testLogger,
519
+ extractBridgeConfigs(config),
520
+ );
521
+
522
+ const event: MonitorEvent = {
523
+ tokensInfo: [
524
+ {
525
+ token: mixedTokensByChainName[chain1],
526
+ bridgedSupply: 50_000_000_000_000_000_000n,
527
+ },
528
+ {
529
+ token: mixedTokensByChainName[chain2],
530
+ bridgedSupply: 200_000_000n,
531
+ },
532
+ ],
533
+ confirmedBlockTags: {
534
+ [chain1]: 1,
535
+ [chain2]: 1,
536
+ },
537
+ };
538
+
539
+ const rawBalances = getRawBalances([chain1, chain2], event, testLogger);
540
+ expect(rawBalances).to.deep.equal({
541
+ [chain1]: 50_000_000n,
542
+ [chain2]: 200_000_000n,
543
+ });
544
+
545
+ const routes = strategy.getRebalancingRoutes(rawBalances);
546
+
547
+ expect(routes).to.deep.equal([
548
+ {
549
+ origin: chain2,
550
+ destination: chain1,
551
+ amount: 70_000_000n,
552
+ bridge: AddressZero,
553
+ executionType: 'movableCollateral',
554
+ },
555
+ ]);
556
+ });
557
+
418
558
  it('should return multiple routes for multiple chains below minimum amount', () => {
419
559
  const config = {
420
560
  [chain1]: {
@@ -514,6 +654,102 @@ describe('MinAmountStrategy', () => {
514
654
  );
515
655
  });
516
656
 
657
+ it('should keep minAmount validation errors human-readable for mixed-decimal routes', () => {
658
+ const mixedTokensByChainName: ChainMap<Token> = {
659
+ [chain1]: new Token({
660
+ ...tokenArgs,
661
+ chainName: chain1,
662
+ decimals: 18,
663
+ scale: { numerator: 1, denominator: 1_000_000_000_000 },
664
+ }),
665
+ [chain2]: new Token({
666
+ ...tokenArgs,
667
+ chainName: chain2,
668
+ decimals: 6,
669
+ }),
670
+ };
671
+
672
+ expect(
673
+ () =>
674
+ new MinAmountStrategy(
675
+ {
676
+ [chain1]: {
677
+ minAmount: {
678
+ min: '100',
679
+ target: '120',
680
+ type: RebalancerMinAmountType.Absolute,
681
+ },
682
+ bridge: AddressZero,
683
+ bridgeLockTime: 1,
684
+ },
685
+ [chain2]: {
686
+ minAmount: {
687
+ min: '100',
688
+ target: '120',
689
+ type: RebalancerMinAmountType.Absolute,
690
+ },
691
+ bridge: AddressZero,
692
+ bridgeLockTime: 1,
693
+ },
694
+ },
695
+ mixedTokensByChainName,
696
+ 230_000_000n,
697
+ testLogger,
698
+ {},
699
+ ),
700
+ ).to.throw(
701
+ `Consider reducing the targets as the sum (240) is greater than sum of collaterals (230)`,
702
+ );
703
+ });
704
+
705
+ it('should keep fractional minAmount validation errors human-readable across heterogeneous decimals', () => {
706
+ const mixedTokensByChainName: ChainMap<Token> = {
707
+ [chain1]: new Token({
708
+ ...tokenArgs,
709
+ chainName: chain1,
710
+ decimals: 6,
711
+ }),
712
+ [chain2]: new Token({
713
+ ...tokenArgs,
714
+ chainName: chain2,
715
+ decimals: 18,
716
+ scale: { numerator: 1, denominator: 1_000_000_000_000 },
717
+ }),
718
+ };
719
+
720
+ expect(
721
+ () =>
722
+ new MinAmountStrategy(
723
+ {
724
+ [chain1]: {
725
+ minAmount: {
726
+ min: '100',
727
+ target: '120.25',
728
+ type: RebalancerMinAmountType.Absolute,
729
+ },
730
+ bridge: AddressZero,
731
+ bridgeLockTime: 1,
732
+ },
733
+ [chain2]: {
734
+ minAmount: {
735
+ min: '100',
736
+ target: '120.25',
737
+ type: RebalancerMinAmountType.Absolute,
738
+ },
739
+ bridge: AddressZero,
740
+ bridgeLockTime: 1,
741
+ },
742
+ },
743
+ mixedTokensByChainName,
744
+ 230_500_000n,
745
+ testLogger,
746
+ {},
747
+ ),
748
+ ).to.throw(
749
+ `Consider reducing the targets as the sum (240.5) is greater than sum of collaterals (230.5)`,
750
+ );
751
+ });
752
+
517
753
  it('should handle case where there is not enough surplus to meet all minimum requirements by scaling down deficits', () => {
518
754
  const config = {
519
755
  [chain1]: {
@@ -1,8 +1,12 @@
1
1
  import { BigNumber } from 'bignumber.js';
2
2
  import { type Logger } from 'pino';
3
3
 
4
- import { type ChainMap, type Token } from '@hyperlane-xyz/sdk';
5
- import { fromWei, toWei } from '@hyperlane-xyz/utils';
4
+ import {
5
+ localAmountFromMessage,
6
+ type ChainMap,
7
+ type Token,
8
+ } from '@hyperlane-xyz/sdk';
9
+ import { fromWei } from '@hyperlane-xyz/utils';
6
10
 
7
11
  import {
8
12
  type MinAmountStrategyConfig,
@@ -16,6 +20,7 @@ import type {
16
20
  } from '../interfaces/IStrategy.js';
17
21
  import { type Metrics } from '../metrics/Metrics.js';
18
22
  import type { BridgeConfigWithOverride } from '../utils/bridgeUtils.js';
23
+ import { normalizeConfiguredAmount } from '../utils/balanceUtils.js';
19
24
 
20
25
  import { BaseStrategy, type Delta } from './BaseStrategy.js';
21
26
 
@@ -116,9 +121,13 @@ export class MinAmountStrategy extends BaseStrategy {
116
121
  if (chainConfig.minAmount.type === RebalancerMinAmountType.Absolute) {
117
122
  const token = this.getTokenByChainName(chain);
118
123
 
119
- minAmount = BigInt(toWei(chainConfig.minAmount.min, token.decimals));
120
- targetAmount = BigInt(
121
- toWei(chainConfig.minAmount.target, token.decimals),
124
+ minAmount = normalizeConfiguredAmount(
125
+ chainConfig.minAmount.min,
126
+ token,
127
+ );
128
+ targetAmount = normalizeConfiguredAmount(
129
+ chainConfig.minAmount.target,
130
+ token,
122
131
  );
123
132
  } else {
124
133
  minAmount = BigInt(
@@ -172,29 +181,32 @@ export class MinAmountStrategy extends BaseStrategy {
172
181
 
173
182
  if (minAmountType === RebalancerMinAmountType.Absolute) {
174
183
  let totalTargets = 0n;
175
- let decimals: number = 0;
184
+ const displayToken = this.getTokenByChainName(this.chains[0]);
176
185
 
177
186
  for (const chainName of this.chains) {
178
187
  const token = this.getTokenByChainName(chainName);
179
- // all the tokens have the same amount of decimals
180
- decimals = token.decimals;
181
-
182
- totalTargets += BigInt(
183
- toWei(config[chainName].minAmount.target, token.decimals),
188
+ totalTargets += normalizeConfiguredAmount(
189
+ config[chainName].minAmount.target,
190
+ token,
184
191
  );
185
192
  }
186
193
 
187
194
  if (totalTargets > totalCollateral) {
188
195
  throw new Error(
189
- `Consider reducing the targets as the sum (${fromWei(
190
- totalTargets.toString(),
191
- decimals,
192
- )}) is greater than sum of collaterals (${fromWei(
193
- totalCollateral.toString(),
194
- decimals,
196
+ `Consider reducing the targets as the sum (${this.formatCanonicalAmount(
197
+ totalTargets,
198
+ displayToken,
199
+ )}) is greater than sum of collaterals (${this.formatCanonicalAmount(
200
+ totalCollateral,
201
+ displayToken,
195
202
  )})`,
196
203
  );
197
204
  }
198
205
  }
199
206
  }
207
+
208
+ private formatCanonicalAmount(amount: bigint, token: Token): string {
209
+ const localAmount = localAmountFromMessage(amount, token.scale);
210
+ return fromWei(localAmount.toString(), token.decimals);
211
+ }
200
212
  }
@@ -6,10 +6,11 @@ import {
6
6
  type ChainMetadata,
7
7
  type ChainName,
8
8
  EvmMovableCollateralAdapter,
9
+ type IToken,
9
10
  type InterchainGasQuote,
10
11
  type MultiProvider,
11
12
  type Token,
12
- type TokenAmount,
13
+ TokenAmount,
13
14
  type WarpCore,
14
15
  } from '@hyperlane-xyz/sdk';
15
16
 
@@ -115,17 +116,14 @@ export function buildTestPreparedTransaction(
115
116
 
116
117
  // === Mock Factories ===
117
118
 
118
- export function createMockTokenAmount(amount: bigint): TokenAmount {
119
- return {
120
- amount,
121
- token: {
122
- name: 'TestToken',
123
- symbol: 'TEST',
124
- decimals: 18,
125
- addressOrDenom: TEST_ADDRESSES.token,
126
- },
127
- getDecimalFormattedAmount: () => ethers.utils.formatEther(amount),
128
- } as unknown as TokenAmount;
119
+ export function createMockTokenAmount(amount: bigint): TokenAmount<IToken> {
120
+ const token = {
121
+ name: 'TestToken',
122
+ symbol: 'TEST',
123
+ decimals: 18,
124
+ addressOrDenom: TEST_ADDRESSES.token,
125
+ } as IToken;
126
+ return new TokenAmount(amount, token);
129
127
  }
130
128
 
131
129
  export interface MockAdapterConfig {
@@ -173,6 +171,7 @@ export interface MockTokenConfig {
173
171
  name?: string;
174
172
  decimals?: number;
175
173
  addressOrDenom?: string;
174
+ scale?: Token['scale'];
176
175
  adapter?: ReturnType<typeof createMockAdapter>;
177
176
  }
178
177
 
@@ -181,6 +180,7 @@ export function createMockToken(config: MockTokenConfig = {}) {
181
180
  name = 'TestToken',
182
181
  decimals = 18,
183
182
  addressOrDenom = TEST_ADDRESSES.token,
183
+ scale,
184
184
  adapter = createMockAdapter(),
185
185
  } = config;
186
186
 
@@ -188,6 +188,7 @@ export function createMockToken(config: MockTokenConfig = {}) {
188
188
  name,
189
189
  decimals,
190
190
  addressOrDenom,
191
+ scale,
191
192
  amount: (amt: bigint) => createMockTokenAmount(amt),
192
193
  getHypAdapter: Sinon.stub().returns(adapter),
193
194
  };
@@ -128,15 +128,18 @@ export function createMockBridgeQuote(
128
128
  const route = overrides?.route as
129
129
  | { action?: { fromChainId?: number; toChainId?: number } }
130
130
  | undefined;
131
+ const usesReverseQuote = overrides?.requestParams?.toAmount !== undefined;
131
132
 
132
133
  const defaultRequestParams: BridgeQuoteParams = {
133
134
  fromChain: 42161,
134
135
  toChain: 1399811149,
135
136
  fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
136
137
  toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
137
- fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
138
138
  toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
139
- fromAmount: 10000000000n,
139
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
140
+ ...(usesReverseQuote
141
+ ? { toAmount: 9950000000n }
142
+ : { fromAmount: 10000000000n }),
140
143
  };
141
144
  const requestParams: BridgeQuoteParams = {
142
145
  ...defaultRequestParams,
@@ -807,6 +807,53 @@ describe('ActionTracker', () => {
807
807
  expect(partialIntents[0].remaining).to.equal(600000000000000000n); // 0.6 ETH remaining
808
808
  });
809
809
 
810
+ it('ignores inventory_movement amounts when computing remaining', async () => {
811
+ await rebalanceIntentStore.save({
812
+ id: 'partial-intent-with-movement',
813
+ status: 'in_progress',
814
+ origin: 1,
815
+ destination: 2,
816
+ amount: 1000000000000000000n,
817
+ executionMethod: 'inventory',
818
+ createdAt: Date.now(),
819
+ updatedAt: Date.now(),
820
+ });
821
+
822
+ await rebalanceActionStore.save({
823
+ id: 'action-deposit',
824
+ type: 'inventory_deposit',
825
+ status: 'complete',
826
+ intentId: 'partial-intent-with-movement',
827
+ origin: 1,
828
+ destination: 2,
829
+ amount: 400000000000000000n,
830
+ createdAt: Date.now(),
831
+ updatedAt: Date.now(),
832
+ });
833
+
834
+ await rebalanceActionStore.save({
835
+ id: 'action-movement',
836
+ type: 'inventory_movement',
837
+ status: 'complete',
838
+ intentId: 'partial-intent-with-movement',
839
+ origin: 3,
840
+ destination: 2,
841
+ amount: 9000000000000000000n,
842
+ createdAt: Date.now(),
843
+ updatedAt: Date.now(),
844
+ });
845
+
846
+ const partialIntents =
847
+ await tracker.getPartiallyFulfilledInventoryIntents();
848
+
849
+ expect(partialIntents).to.have.lengthOf(1);
850
+ expect(partialIntents[0].intent.id).to.equal(
851
+ 'partial-intent-with-movement',
852
+ );
853
+ expect(partialIntents[0].completedAmount).to.equal(400000000000000000n);
854
+ expect(partialIntents[0].remaining).to.equal(600000000000000000n);
855
+ });
856
+
810
857
  it('does not return non-inventory intents', async () => {
811
858
  // Create a not_started intent without executionMethod: 'inventory'
812
859
  await rebalanceIntentStore.save({
@@ -636,7 +636,8 @@ export class ActionTracker implements IActionTracker {
636
636
  );
637
637
  }
638
638
 
639
- // Compute amounts from action states
639
+ // Only inventory_deposit amounts advance fulfillment. inventory_movement
640
+ // amounts stay in source-local units and only gate retries/stale cleanup.
640
641
  const completedAmount = actions
641
642
  .filter(
642
643
  (a) => a.status === 'complete' && a.type === 'inventory_deposit',
@@ -6,7 +6,15 @@ import { type ChainName, type Token, TokenStandard } from '@hyperlane-xyz/sdk';
6
6
 
7
7
  import { type MonitorEvent } from '../interfaces/IMonitor.js';
8
8
 
9
- import { getRawBalances } from './balanceUtils.js';
9
+ import {
10
+ alignLocalToCanonical,
11
+ denormalizeToLocal,
12
+ getRawBalances,
13
+ isIdentityScale,
14
+ getTokenScale,
15
+ normalizeConfiguredAmount,
16
+ normalizeToCanonical,
17
+ } from './balanceUtils.js';
10
18
 
11
19
  const testLogger = pino({ level: 'silent' });
12
20
 
@@ -45,6 +53,16 @@ describe('getRawBalances', () => {
45
53
  });
46
54
  });
47
55
 
56
+ it('should normalize bridged supply to canonical units when token has scale', () => {
57
+ token.scale = { numerator: 1, denominator: 1_000_000_000_000 };
58
+ tokenBridgedSupply = 1_000_000_000_000_000_000n;
59
+ event.tokensInfo[0].bridgedSupply = tokenBridgedSupply;
60
+
61
+ expect(getRawBalances(chains, event, testLogger)).to.deep.equal({
62
+ mainnet: 1_000_000n,
63
+ });
64
+ });
65
+
48
66
  it('should return the bridged supply for the token (EvmHypNative)', () => {
49
67
  token.standard = TokenStandard.EvmHypNative;
50
68
 
@@ -73,3 +91,71 @@ describe('getRawBalances', () => {
73
91
  );
74
92
  });
75
93
  });
94
+
95
+ describe('scale helpers', () => {
96
+ const token = {
97
+ decimals: 18,
98
+ scale: { numerator: 1, denominator: 1_000_000_000_000 },
99
+ } as unknown as Token;
100
+
101
+ it('normalizes local amount to canonical amount', () => {
102
+ expect(normalizeToCanonical(1_000_000_000_000_000_000n, token)).to.equal(
103
+ 1_000_000n,
104
+ );
105
+ });
106
+
107
+ it('denormalizes canonical amount to local amount', () => {
108
+ expect(denormalizeToLocal(1_000_000n, token)).to.equal(
109
+ 1_000_000_000_000_000_000n,
110
+ );
111
+ });
112
+
113
+ it('aligns local amount to exact canonical progress', () => {
114
+ expect(alignLocalToCanonical(999_999_999_999n, token)).to.deep.equal({
115
+ localAmount: 0n,
116
+ messageAmount: 0n,
117
+ });
118
+ expect(
119
+ alignLocalToCanonical(1_000_000_000_000_500_000n, token),
120
+ ).to.deep.equal({
121
+ localAmount: 1_000_000_000_000_000_000n,
122
+ messageAmount: 1_000_000n,
123
+ });
124
+ });
125
+
126
+ it('returns identity scale when scale is undefined', () => {
127
+ expect(getTokenScale({} as Token)).to.deep.equal({
128
+ numerator: 1n,
129
+ denominator: 1n,
130
+ });
131
+ });
132
+
133
+ it('treats equivalent ratios as identity scale', () => {
134
+ expect(isIdentityScale({} as Token)).to.be.true;
135
+ expect(
136
+ isIdentityScale({
137
+ scale: { numerator: 1, denominator: 1 },
138
+ } as Token),
139
+ ).to.be.true;
140
+ expect(
141
+ isIdentityScale({
142
+ scale: { numerator: 5, denominator: 5 },
143
+ } as Token),
144
+ ).to.be.true;
145
+ expect(
146
+ isIdentityScale({
147
+ scale: { numerator: 10, denominator: 20 },
148
+ } as Token),
149
+ ).to.be.false;
150
+ });
151
+
152
+ it('normalizes configured amount using token decimals and scale', () => {
153
+ expect(normalizeConfiguredAmount('1', token)).to.equal(1_000_000n);
154
+ });
155
+
156
+ it('normalizes sub-unit local dust to zero canonical amount', () => {
157
+ expect(normalizeConfiguredAmount('0.000000000000000001', token)).to.equal(
158
+ 0n,
159
+ );
160
+ });
161
+ });