@hyperlane-xyz/rebalancer 27.2.13 → 27.3.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 (55) hide show
  1. package/dist/bridges/LiFiBridge.d.ts +3 -3
  2. package/dist/bridges/LiFiBridge.d.ts.map +1 -1
  3. package/dist/bridges/LiFiBridge.js +60 -52
  4. package/dist/bridges/LiFiBridge.js.map +1 -1
  5. package/dist/bridges/LiFiBridge.test.js +213 -0
  6. package/dist/bridges/LiFiBridge.test.js.map +1 -1
  7. package/dist/config/RebalancerConfig.test.js +123 -0
  8. package/dist/config/RebalancerConfig.test.js.map +1 -1
  9. package/dist/config/types.d.ts.map +1 -1
  10. package/dist/config/types.js +9 -0
  11. package/dist/config/types.js.map +1 -1
  12. package/dist/core/InventoryRebalancer.d.ts +4 -2
  13. package/dist/core/InventoryRebalancer.d.ts.map +1 -1
  14. package/dist/core/InventoryRebalancer.js +84 -25
  15. package/dist/core/InventoryRebalancer.js.map +1 -1
  16. package/dist/core/InventoryRebalancer.test.js +436 -21
  17. package/dist/core/InventoryRebalancer.test.js.map +1 -1
  18. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  19. package/dist/factories/RebalancerContextFactory.js +34 -24
  20. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  21. package/dist/factories/RebalancerContextFactory.test.js +84 -1
  22. package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
  23. package/dist/service.js +7 -4
  24. package/dist/service.js.map +1 -1
  25. package/dist/utils/blockTag.d.ts.map +1 -1
  26. package/dist/utils/blockTag.js +8 -3
  27. package/dist/utils/blockTag.js.map +1 -1
  28. package/dist/utils/blockTag.test.d.ts +2 -0
  29. package/dist/utils/blockTag.test.d.ts.map +1 -0
  30. package/dist/utils/blockTag.test.js +57 -0
  31. package/dist/utils/blockTag.test.js.map +1 -0
  32. package/dist/utils/gasEstimation.js +4 -4
  33. package/dist/utils/gasEstimation.js.map +1 -1
  34. package/dist/utils/gasEstimation.test.d.ts +2 -0
  35. package/dist/utils/gasEstimation.test.d.ts.map +1 -0
  36. package/dist/utils/gasEstimation.test.js +63 -0
  37. package/dist/utils/gasEstimation.test.js.map +1 -0
  38. package/dist/utils/tokenUtils.d.ts.map +1 -1
  39. package/dist/utils/tokenUtils.js +5 -2
  40. package/dist/utils/tokenUtils.js.map +1 -1
  41. package/package.json +7 -7
  42. package/src/bridges/LiFiBridge.test.ts +270 -0
  43. package/src/bridges/LiFiBridge.ts +83 -68
  44. package/src/config/RebalancerConfig.test.ts +135 -0
  45. package/src/config/types.ts +8 -0
  46. package/src/core/InventoryRebalancer.test.ts +610 -21
  47. package/src/core/InventoryRebalancer.ts +121 -30
  48. package/src/factories/RebalancerContextFactory.test.ts +116 -1
  49. package/src/factories/RebalancerContextFactory.ts +38 -28
  50. package/src/service.ts +11 -8
  51. package/src/utils/blockTag.test.ts +70 -0
  52. package/src/utils/blockTag.ts +11 -3
  53. package/src/utils/gasEstimation.test.ts +99 -0
  54. package/src/utils/gasEstimation.ts +4 -4
  55. package/src/utils/tokenUtils.ts +5 -2
@@ -15,7 +15,13 @@ import {
15
15
  getSignerForChain,
16
16
  type TypedTransactionReceipt,
17
17
  } from '@hyperlane-xyz/sdk';
18
- import { ProtocolType, assert, ensure0x, fromWei } from '@hyperlane-xyz/utils';
18
+ import {
19
+ ProtocolType,
20
+ assert,
21
+ ensure0x,
22
+ fromWei,
23
+ isEVMLike,
24
+ } from '@hyperlane-xyz/utils';
19
25
 
20
26
  import type { ExternalBridgeType } from '../config/types.js';
21
27
  import type {
@@ -74,6 +80,21 @@ type BridgeCapacity = {
74
80
  maxTargetOutput: bigint;
75
81
  };
76
82
 
83
+ type BridgeQuoteMode = 'forward' | 'reverse';
84
+
85
+ type InventoryMovementExecutionResult =
86
+ | {
87
+ success: true;
88
+ txHash: string;
89
+ inputRequired: bigint;
90
+ quotedOutput: bigint;
91
+ quotedOutputMin: bigint;
92
+ quoteModeUsed: BridgeQuoteMode;
93
+ }
94
+ | {
95
+ success: false;
96
+ error: string;
97
+ };
77
98
  const RECOVERABLE_MAX_TRANSFER_ERROR_MESSAGES = [
78
99
  'balance may be insufficient',
79
100
  'transfer amount exceeds balance',
@@ -910,6 +931,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
910
931
  chain: ChainName;
911
932
  maxSourceInput: bigint;
912
933
  targetOutput: bigint;
934
+ quoteMode: BridgeQuoteMode;
913
935
  }> = [];
914
936
  let totalPlanned = 0n;
915
937
 
@@ -921,11 +943,14 @@ export class InventoryRebalancer implements IInventoryRebalancer {
921
943
  source.maxTargetOutput >= remaining
922
944
  ? remaining
923
945
  : source.maxTargetOutput;
946
+ const quoteMode: BridgeQuoteMode =
947
+ source.maxTargetOutput > remaining ? 'reverse' : 'forward';
924
948
 
925
949
  bridgePlans.push({
926
950
  chain: source.chain,
927
951
  maxSourceInput: source.maxSourceInput,
928
952
  targetOutput,
953
+ quoteMode,
929
954
  });
930
955
  totalPlanned += targetOutput;
931
956
  }
@@ -942,6 +967,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
942
967
  chain: p.chain,
943
968
  maxSourceInput: p.maxSourceInput.toString(),
944
969
  targetOutput: p.targetOutput.toString(),
970
+ quoteMode: p.quoteMode,
945
971
  })),
946
972
  totalPlanned: totalPlanned.toString(),
947
973
  shortfall: shortfall.toString(),
@@ -959,6 +985,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
959
985
  destination,
960
986
  plan.targetOutput,
961
987
  plan.maxSourceInput,
988
+ plan.quoteMode,
962
989
  intent,
963
990
  route.externalBridge,
964
991
  ),
@@ -967,7 +994,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
967
994
 
968
995
  // Process results
969
996
  let successCount = 0;
970
- let totalBridged = 0n;
997
+ let totalQuotedOutputMin = 0n;
971
998
  const failedErrors: string[] = [];
972
999
 
973
1000
  for (let i = 0; i < bridgeResults.length; i++) {
@@ -976,27 +1003,42 @@ export class InventoryRebalancer implements IInventoryRebalancer {
976
1003
 
977
1004
  if (result.status === 'fulfilled' && result.value.success) {
978
1005
  successCount++;
979
- totalBridged += plan.targetOutput;
1006
+ totalQuotedOutputMin += result.value.quotedOutputMin;
980
1007
  this.logger.info(
981
1008
  {
982
1009
  sourceChain: plan.chain,
983
- amount: plan.targetOutput.toString(),
1010
+ plannedTargetOutput: plan.targetOutput.toString(),
1011
+ quotedOutput: result.value.quotedOutput.toString(),
1012
+ quotedOutputMin: result.value.quotedOutputMin.toString(),
1013
+ quoteModeUsed: result.value.quoteModeUsed,
984
1014
  txHash: result.value.txHash,
985
1015
  },
986
1016
  'Inventory movement succeeded',
987
1017
  );
988
1018
  } else {
989
- const error =
990
- result.status === 'rejected'
991
- ? result.reason?.message
992
- : result.value.error;
1019
+ let error: string | undefined;
1020
+ if (result.status === 'rejected') {
1021
+ if (result.reason instanceof Error) {
1022
+ error = result.reason.message;
1023
+ } else if (typeof result.reason === 'string') {
1024
+ error = result.reason;
1025
+ } else {
1026
+ try {
1027
+ error = JSON.stringify(result.reason) ?? String(result.reason);
1028
+ } catch {
1029
+ error = String(result.reason);
1030
+ }
1031
+ }
1032
+ } else if (!result.value.success) {
1033
+ error = result.value.error;
1034
+ }
993
1035
  if (error) {
994
1036
  failedErrors.push(`${plan.chain}: ${error}`);
995
1037
  }
996
1038
  this.logger.warn(
997
1039
  {
998
1040
  sourceChain: plan.chain,
999
- amount: plan.targetOutput.toString(),
1041
+ plannedTargetOutput: plan.targetOutput.toString(),
1000
1042
  error,
1001
1043
  },
1002
1044
  'Inventory movement failed',
@@ -1018,7 +1060,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1018
1060
  {
1019
1061
  targetChain: destination,
1020
1062
  successCount,
1021
- totalBridged: totalBridged.toString(),
1063
+ totalQuotedOutputMin: totalQuotedOutputMin.toString(),
1022
1064
  targetAmount: requestedLocalAmount.toString(),
1023
1065
  targetAmountCanonical: amount.toString(),
1024
1066
  intentId: intent.id,
@@ -1184,6 +1226,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1184
1226
  ): MultiProtocolSignerSignerAccountInfo {
1185
1227
  void chain;
1186
1228
  switch (protocol) {
1229
+ case ProtocolType.Tron:
1187
1230
  case ProtocolType.Ethereum:
1188
1231
  return { protocol, privateKey: ensure0x(key) };
1189
1232
  case ProtocolType.Sealevel:
@@ -1223,7 +1266,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1223
1266
  try {
1224
1267
  const protocol = this.getProtocolForChain(origin);
1225
1268
 
1226
- if (protocol === ProtocolType.Ethereum) {
1269
+ if (isEVMLike(protocol)) {
1227
1270
  const provider =
1228
1271
  this.warpCore.multiProvider.getEthersV5Provider(origin);
1229
1272
  const receipt = await provider.getTransactionReceipt(txHash);
@@ -1395,13 +1438,15 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1395
1438
  /**
1396
1439
  * Execute inventory movement from source chain to target chain via LiFi bridge.
1397
1440
  *
1398
- * Uses reverse quotes (`toAmount`) so plans are expressed in target-chain local
1399
- * units and source-local spend is discovered by the bridge quote.
1441
+ * Quote mode is chosen during planning:
1442
+ * - `reverse`: request an exact target-chain output when the source has headroom
1443
+ * - `forward`: spend the source cap directly when source inventory is the limiter
1400
1444
  *
1401
1445
  * @param sourceChain - Chain to move inventory from
1402
1446
  * @param targetChain - Chain to move inventory to (origin chain for rebalancing)
1403
1447
  * @param targetOutputAmount - Destination-local amount to receive
1404
1448
  * @param maxSourceInput - Maximum source-local amount available for this plan
1449
+ * @param quoteMode - Whether to execute this bridge plan as exact-input or exact-output
1405
1450
  * @param intent - Rebalance intent for tracking
1406
1451
  * @param externalBridgeType - External bridge type to use
1407
1452
  * @returns Result with success status and optional txHash/error
@@ -1411,9 +1456,10 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1411
1456
  targetChain: ChainName,
1412
1457
  targetOutputAmount: bigint,
1413
1458
  maxSourceInput: bigint,
1459
+ quoteMode: BridgeQuoteMode,
1414
1460
  intent: RebalanceIntent,
1415
1461
  externalBridgeType: ExternalBridgeType,
1416
- ): Promise<{ success: boolean; txHash?: string; error?: string }> {
1462
+ ): Promise<InventoryMovementExecutionResult> {
1417
1463
  const sourceToken = this.getTokenForChain(sourceChain);
1418
1464
  if (!sourceToken) {
1419
1465
  return {
@@ -1461,15 +1507,43 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1461
1507
 
1462
1508
  try {
1463
1509
  const externalBridge = this.getExternalBridge(externalBridgeType);
1464
- const quote = await externalBridge.quote({
1465
- fromChain: sourceChainId,
1466
- toChain: targetChainId,
1467
- fromToken: fromTokenAddress,
1468
- toToken: toTokenAddress,
1469
- toAmount: targetOutputAmount,
1470
- fromAddress: this.getInventorySignerAddress(sourceChain),
1471
- toAddress: this.getInventorySignerAddress(targetChain),
1472
- });
1510
+ const fromAddress = this.getInventorySignerAddress(sourceChain);
1511
+ const toAddress = this.getInventorySignerAddress(targetChain);
1512
+ const quoteWithMode = async (mode: BridgeQuoteMode) =>
1513
+ externalBridge.quote({
1514
+ fromChain: sourceChainId,
1515
+ toChain: targetChainId,
1516
+ fromToken: fromTokenAddress,
1517
+ toToken: toTokenAddress,
1518
+ ...(mode === 'forward'
1519
+ ? { fromAmount: maxSourceInput }
1520
+ : { toAmount: targetOutputAmount }),
1521
+ fromAddress,
1522
+ toAddress,
1523
+ });
1524
+
1525
+ let quoteModeUsed = quoteMode;
1526
+ let quote = await quoteWithMode(quoteModeUsed);
1527
+
1528
+ if (quoteModeUsed === 'reverse' && quote.fromAmount > maxSourceInput) {
1529
+ this.logger.warn(
1530
+ {
1531
+ sourceChain,
1532
+ targetChain,
1533
+ plannedQuoteMode: quoteMode,
1534
+ requestedTargetOutput: targetOutputAmount.toString(),
1535
+ quotedInput: quote.fromAmount.toString(),
1536
+ maxSourceInput: maxSourceInput.toString(),
1537
+ intentId: intent.id,
1538
+ },
1539
+ 'Reverse bridge quote exceeded source capacity, retrying with forward quote',
1540
+ );
1541
+
1542
+ // Spend the full source cap on fallback; minor output drift is acceptable
1543
+ // and will be reconciled by later cycles rather than risking livelock.
1544
+ quoteModeUsed = 'forward';
1545
+ quote = await quoteWithMode(quoteModeUsed);
1546
+ }
1473
1547
 
1474
1548
  const inputRequired = quote.fromAmount;
1475
1549
  if (inputRequired > maxSourceInput) {
@@ -1490,22 +1564,30 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1490
1564
  targetOutputAmount,
1491
1565
  targetToken,
1492
1566
  ),
1567
+ quoteModePlanned: quoteMode,
1568
+ quoteModeUsed,
1569
+ retriedAsForward:
1570
+ quoteMode === 'reverse' && quoteModeUsed === 'forward',
1493
1571
  inputRequired: inputRequired.toString(),
1494
1572
  inputRequiredFormatted: this.formatLocalAmount(
1495
1573
  inputRequired,
1496
1574
  sourceToken,
1497
1575
  ),
1498
- expectedOutput: quote.toAmount.toString(),
1499
- expectedOutputMin: quote.toAmountMin.toString(),
1500
- expectedOutputFormatted: this.formatLocalAmount(
1576
+ quotedOutput: quote.toAmount.toString(),
1577
+ quotedOutputMin: quote.toAmountMin.toString(),
1578
+ quotedOutputFormatted: this.formatLocalAmount(
1501
1579
  quote.toAmount,
1502
1580
  targetToken,
1503
1581
  ),
1582
+ quotedOutputMinFormatted: this.formatLocalAmount(
1583
+ quote.toAmountMin,
1584
+ targetToken,
1585
+ ),
1504
1586
  gasCosts: quote.gasCosts.toString(),
1505
1587
  feeCosts: quote.feeCosts.toString(),
1506
1588
  intentId: intent.id,
1507
1589
  },
1508
- 'Executing inventory movement via LiFi reverse quote',
1590
+ 'Executing inventory movement via bridge quote',
1509
1591
  );
1510
1592
 
1511
1593
  this.logger.debug(
@@ -1573,22 +1655,31 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1573
1655
  'Updated consumed inventory after LiFi bridge',
1574
1656
  );
1575
1657
 
1576
- return { success: true, txHash: result.txHash };
1658
+ return {
1659
+ success: true,
1660
+ txHash: result.txHash,
1661
+ inputRequired,
1662
+ quotedOutput: quote.toAmount,
1663
+ quotedOutputMin: quote.toAmountMin,
1664
+ quoteModeUsed,
1665
+ };
1577
1666
  } catch (error) {
1667
+ const errorMessage =
1668
+ error instanceof Error ? error.message : String(error);
1578
1669
  this.logger.error(
1579
1670
  {
1580
1671
  sourceChain,
1581
1672
  targetChain,
1582
1673
  amount: targetOutputAmount.toString(),
1583
1674
  intentId: intent.id,
1584
- error: (error as Error).message,
1675
+ error: errorMessage,
1585
1676
  },
1586
1677
  'Failed to execute inventory movement',
1587
1678
  );
1588
1679
 
1589
1680
  return {
1590
1681
  success: false,
1591
- error: (error as Error).message,
1682
+ error: errorMessage,
1592
1683
  };
1593
1684
  }
1594
1685
  }
@@ -218,6 +218,36 @@ describe('RebalancerContextFactory', () => {
218
218
  expect(multiProvider.getProvider.firstCall.args[0]).to.equal('ethereum');
219
219
  });
220
220
 
221
+ it('should initialize providers for Tron chains (EVM-like)', async () => {
222
+ const { multiProvider } = createMockMultiProvider([
223
+ { name: 'ethereum', protocol: ProtocolType.Ethereum },
224
+ { name: 'tron', protocol: ProtocolType.Tron },
225
+ ]);
226
+
227
+ await callCreate(multiProvider, {
228
+ tokens: [
229
+ createToken(
230
+ 'ethereum',
231
+ TEST_ADDRESSES.ethereum,
232
+ TokenStandard.EvmHypCollateral,
233
+ ),
234
+ createToken(
235
+ 'tron',
236
+ '0xTronToken1234567890',
237
+ TokenStandard.TronHypCollateral,
238
+ ),
239
+ ],
240
+ });
241
+
242
+ // Tron is EVM-like, so getProvider should be called for both chains
243
+ expect(multiProvider.getProvider.callCount).to.equal(2);
244
+ const providerChains = multiProvider.getProvider
245
+ .getCalls()
246
+ .map((c) => c.args[0]);
247
+ expect(providerChains).to.include('ethereum');
248
+ expect(providerChains).to.include('tron');
249
+ });
250
+
221
251
  it('should call getProvider for all chains when all are EVM', async () => {
222
252
  const { multiProvider } = createMockMultiProvider([
223
253
  { name: 'ethereum', protocol: ProtocolType.Ethereum },
@@ -363,7 +393,7 @@ describe('RebalancerContextFactory', () => {
363
393
  createToken(
364
394
  evmChain,
365
395
  TEST_ADDRESSES.ethereum,
366
- TokenStandard.EvmHypSynthetic,
396
+ TokenStandard.EvmHypCollateral,
367
397
  ),
368
398
  createToken(
369
399
  sealevelChain,
@@ -446,6 +476,91 @@ describe('RebalancerContextFactory', () => {
446
476
  );
447
477
  });
448
478
 
479
+ it('should accept Tron as a supported inventory protocol', async () => {
480
+ const tronChain = 'tron';
481
+ const evmChain = 'ethereum';
482
+ const { multiProvider } = createMockMultiProvider([
483
+ { name: evmChain, protocol: ProtocolType.Ethereum },
484
+ { name: tronChain, protocol: ProtocolType.Tron },
485
+ ]);
486
+
487
+ const config = {
488
+ warpRouteId: 'USDC/tron-route',
489
+ strategyConfig: [
490
+ {
491
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
492
+ chains: {
493
+ [tronChain]: {
494
+ bridge: TEST_ADDRESSES.bridge,
495
+ weighted: { weight: 50n, tolerance: 10n },
496
+ override: {
497
+ [evmChain]: {
498
+ executionType: ExecutionType.Inventory,
499
+ },
500
+ },
501
+ },
502
+ [evmChain]: {
503
+ bridge: TEST_ADDRESSES.bridge,
504
+ weighted: { weight: 50n, tolerance: 10n },
505
+ },
506
+ },
507
+ },
508
+ ],
509
+ inventorySigners: {
510
+ [ProtocolType.Ethereum]: {
511
+ address: TEST_ADDRESSES.ethereum,
512
+ key: '0xabc123',
513
+ },
514
+ [ProtocolType.Tron]: {
515
+ address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
516
+ key: '0xdef456',
517
+ },
518
+ },
519
+ intentTTL: DEFAULT_INTENT_TTL_MS,
520
+ } as RebalancerConfig;
521
+
522
+ const factory = await createFactory(config, multiProvider, {
523
+ tokens: [
524
+ createToken(
525
+ evmChain,
526
+ TEST_ADDRESSES.ethereum,
527
+ TokenStandard.EvmHypCollateral,
528
+ ),
529
+ createToken(
530
+ tronChain,
531
+ '0xTronToken123',
532
+ TokenStandard.TronHypCollateral,
533
+ ),
534
+ ],
535
+ });
536
+
537
+ const getChainMetadataStub = factory.getWarpCore().multiProvider
538
+ .getChainMetadata as Sinon.SinonStub;
539
+ getChainMetadataStub.callsFake((chainName: string) => ({
540
+ protocol:
541
+ chainName === tronChain ? ProtocolType.Tron : ProtocolType.Ethereum,
542
+ }));
543
+
544
+ const result = await (factory as any).createInventoryRebalancerAndConfig(
545
+ {} as any,
546
+ {},
547
+ );
548
+
549
+ assert(
550
+ result,
551
+ 'Expected inventory config to be created for Tron support',
552
+ );
553
+ expect(result.inventoryConfig.inventoryAddresses).to.deep.equal({
554
+ [ProtocolType.Ethereum]: TEST_ADDRESSES.ethereum,
555
+ [ProtocolType.Tron]: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
556
+ });
557
+ expect(result.inventoryConfig.chains).to.include.members([
558
+ evmChain,
559
+ tronChain,
560
+ ]);
561
+ expect(result.inventoryRebalancer).to.exist;
562
+ });
563
+
449
564
  it('should fail early when inventory chain uses unsupported protocol', async () => {
450
565
  const cosmosChain = 'cosmoshub';
451
566
  const evmChain = 'ethereum';
@@ -11,7 +11,13 @@ import {
11
11
  WarpCore,
12
12
  type WarpCoreConfig,
13
13
  } from '@hyperlane-xyz/sdk';
14
- import { Address, assert, ProtocolType, objMap } from '@hyperlane-xyz/utils';
14
+ import {
15
+ Address,
16
+ assert,
17
+ isEVMLike,
18
+ ProtocolType,
19
+ objMap,
20
+ } from '@hyperlane-xyz/utils';
15
21
 
16
22
  import { LiFiBridge } from '../bridges/LiFiBridge.js';
17
23
  import { type RebalancerConfig } from '../config/RebalancerConfig.js';
@@ -137,7 +143,7 @@ export class RebalancerContextFactory {
137
143
  ...new Set(warpCoreConfig.tokens.map((t) => t.chainName)),
138
144
  ];
139
145
  for (const chain of warpChains) {
140
- if (multiProvider.getProtocol(chain) !== ProtocolType.Ethereum) {
146
+ if (!isEVMLike(multiProvider.getProtocol(chain))) {
141
147
  logger.debug({ chain }, 'Skipping provider init for non-EVM chain');
142
148
  continue;
143
149
  }
@@ -373,11 +379,12 @@ export class RebalancerContextFactory {
373
379
  bridges,
374
380
  rebalancerAddress,
375
381
  inventorySignerAddresses: this.config.inventorySigners
376
- ? (Object.entries(this.config.inventorySigners)
377
- .filter(([protocol]) => protocol === ProtocolType.Ethereum)
378
- .map(([, signerConfig]) => signerConfig)
379
- .map((s) => s.address)
380
- .filter(Boolean) as Address[])
382
+ ? Object.values(ProtocolType)
383
+ .filter((protocol) => isEVMLike(protocol))
384
+ .map(
385
+ (protocol) => this.config.inventorySigners?.[protocol]?.address,
386
+ )
387
+ .filter((address): address is Address => Boolean(address))
381
388
  : undefined,
382
389
  intentTTL: this.config.intentTTL,
383
390
  };
@@ -481,6 +488,7 @@ export class RebalancerContextFactory {
481
488
  const SUPPORTED_INVENTORY_PROTOCOLS = new Set([
482
489
  ProtocolType.Ethereum,
483
490
  ProtocolType.Sealevel,
491
+ ProtocolType.Tron,
484
492
  ]);
485
493
  for (const protocol of requiredProtocols) {
486
494
  const chainsForProtocol = allRelevantChains.filter(
@@ -490,7 +498,7 @@ export class RebalancerContextFactory {
490
498
  );
491
499
  assert(
492
500
  SUPPORTED_INVENTORY_PROTOCOLS.has(protocol),
493
- `Inventory rebalancing does not support protocol '${protocol}' (chains: ${chainsForProtocol.join(', ')}). Supported: ethereum, sealevel`,
501
+ `Inventory rebalancing does not support protocol '${protocol}' (chains: ${chainsForProtocol.join(', ')}). Supported: ethereum, sealevel, tron`,
494
502
  );
495
503
  }
496
504
  for (const protocol of requiredProtocols) {
@@ -532,12 +540,12 @@ export class RebalancerContextFactory {
532
540
  'No external bridges configured, skipping inventory components',
533
541
  );
534
542
  }
535
- const inventoryAddresses = Object.fromEntries(
536
- Object.entries(inventorySigners).map(([protocol, cfg]) => [
537
- protocol,
538
- cfg.address,
539
- ]),
540
- ) as Partial<Record<ProtocolType, Address>>;
543
+ const inventoryAddresses: Partial<Record<ProtocolType, Address>> = {};
544
+ for (const protocol of Object.values(ProtocolType)) {
545
+ const cfg = inventorySigners[protocol];
546
+ if (!cfg) continue;
547
+ inventoryAddresses[protocol] = cfg.address;
548
+ }
541
549
  const inventoryConfig: InventoryMonitorConfig = {
542
550
  inventoryAddresses,
543
551
  chains: allRelevantChains,
@@ -546,11 +554,12 @@ export class RebalancerContextFactory {
546
554
  const mergedSigners: Partial<
547
555
  Record<ProtocolType, InventorySignerConfig>
548
556
  > = {};
549
- for (const [protocol, cfg] of Object.entries(inventorySigners)) {
550
- const protocolKey = protocol as ProtocolType;
551
- mergedSigners[protocolKey] = {
557
+ for (const protocol of Object.values(ProtocolType)) {
558
+ const cfg = inventorySigners[protocol];
559
+ if (!cfg) continue;
560
+ mergedSigners[protocol] = {
552
561
  address: cfg.address,
553
- key: cfg.key ?? this.inventorySignerKeysByProtocol?.[protocolKey],
562
+ key: cfg.key ?? this.inventorySignerKeysByProtocol?.[protocol],
554
563
  };
555
564
  }
556
565
  const inventoryRebalancer = new InventoryRebalancer(
@@ -572,12 +581,12 @@ export class RebalancerContextFactory {
572
581
  }
573
582
 
574
583
  // 3. Build inventory config
575
- const inventoryAddresses = Object.fromEntries(
576
- Object.entries(inventorySigners).map(([protocol, cfg]) => [
577
- protocol,
578
- cfg.address,
579
- ]),
580
- ) as Partial<Record<ProtocolType, Address>>;
584
+ const inventoryAddresses: Partial<Record<ProtocolType, Address>> = {};
585
+ for (const protocol of Object.values(ProtocolType)) {
586
+ const cfg = inventorySigners[protocol];
587
+ if (!cfg) continue;
588
+ inventoryAddresses[protocol] = cfg.address;
589
+ }
581
590
  const inventoryConfig: InventoryMonitorConfig = {
582
591
  inventoryAddresses,
583
592
  chains: allRelevantChains,
@@ -587,11 +596,12 @@ export class RebalancerContextFactory {
587
596
  // Merge config addresses with runtime keys
588
597
  const mergedSigners: Partial<Record<ProtocolType, InventorySignerConfig>> =
589
598
  {};
590
- for (const [protocol, cfg] of Object.entries(inventorySigners)) {
591
- const protocolKey = protocol as ProtocolType;
592
- mergedSigners[protocolKey] = {
599
+ for (const protocol of Object.values(ProtocolType)) {
600
+ const cfg = inventorySigners[protocol];
601
+ if (!cfg) continue;
602
+ mergedSigners[protocol] = {
593
603
  address: cfg.address,
594
- key: cfg.key ?? this.inventorySignerKeysByProtocol?.[protocolKey],
604
+ key: cfg.key ?? this.inventorySignerKeysByProtocol?.[protocol],
595
605
  };
596
606
  }
597
607
  const inventoryRebalancer = new InventoryRebalancer(
package/src/service.ts CHANGED
@@ -33,6 +33,7 @@ import { MultiProvider } from '@hyperlane-xyz/sdk';
33
33
  import {
34
34
  applyRpcUrlOverridesFromEnv,
35
35
  createServiceLogger,
36
+ isEVMLike,
36
37
  ProtocolType,
37
38
  rootLogger,
38
39
  } from '@hyperlane-xyz/utils';
@@ -151,12 +152,15 @@ async function main(): Promise<void> {
151
152
  Record<ProtocolType, InventorySignerConfig>
152
153
  > = {};
153
154
 
154
- for (const [protocol, privateKey] of Object.entries(inventoryPrivateKeys)) {
155
+ for (const protocol of Object.values(ProtocolType)) {
156
+ const privateKey = inventoryPrivateKeys[protocol];
155
157
  if (!privateKey) continue;
156
158
 
157
159
  let derivedAddress: string;
158
160
 
159
- if (protocol === ProtocolType.Ethereum) {
161
+ if (isEVMLike(protocol)) {
162
+ // Tron uses same hex private key format as Ethereum.
163
+ // Derive 0x-prefixed hex address via ethers Wallet (TronWallet extends Wallet).
160
164
  derivedAddress = new Wallet(privateKey).address;
161
165
  } else if (protocol === ProtocolType.Sealevel) {
162
166
  const keyBytes = parseSolanaPrivateKey(privateKey);
@@ -172,12 +176,11 @@ async function main(): Promise<void> {
172
176
 
173
177
  // Validate against config if present
174
178
  const configuredAddress =
175
- rebalancerConfig.inventorySigners?.[protocol as ProtocolType]?.address;
179
+ rebalancerConfig.inventorySigners?.[protocol]?.address;
176
180
  if (configuredAddress) {
177
- const mismatch =
178
- protocol === ProtocolType.Ethereum
179
- ? configuredAddress.toLowerCase() !== derivedAddress.toLowerCase()
180
- : configuredAddress !== derivedAddress;
181
+ const mismatch = isEVMLike(protocol)
182
+ ? configuredAddress.toLowerCase() !== derivedAddress.toLowerCase()
183
+ : configuredAddress !== derivedAddress;
181
184
  if (mismatch) {
182
185
  throw new Error(
183
186
  `inventorySigners.${protocol} mismatch: config has ${configuredAddress} but HYP_INVENTORY_KEY_${protocol.toUpperCase()} derives to ${derivedAddress}`,
@@ -185,7 +188,7 @@ async function main(): Promise<void> {
185
188
  }
186
189
  }
187
190
 
188
- inventorySigners[protocol as ProtocolType] = {
191
+ inventorySigners[protocol] = {
189
192
  address: derivedAddress,
190
193
  key: privateKey,
191
194
  };