@hyperlane-xyz/rebalancer 27.2.14 → 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 (54) 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 +59 -51
  4. package/dist/bridges/LiFiBridge.js.map +1 -1
  5. package/dist/bridges/LiFiBridge.test.js +176 -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.map +1 -1
  13. package/dist/core/InventoryRebalancer.js +3 -2
  14. package/dist/core/InventoryRebalancer.js.map +1 -1
  15. package/dist/core/InventoryRebalancer.test.js +108 -1
  16. package/dist/core/InventoryRebalancer.test.js.map +1 -1
  17. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  18. package/dist/factories/RebalancerContextFactory.js +34 -24
  19. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  20. package/dist/factories/RebalancerContextFactory.test.js +84 -1
  21. package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
  22. package/dist/service.js +7 -4
  23. package/dist/service.js.map +1 -1
  24. package/dist/utils/blockTag.d.ts.map +1 -1
  25. package/dist/utils/blockTag.js +8 -3
  26. package/dist/utils/blockTag.js.map +1 -1
  27. package/dist/utils/blockTag.test.d.ts +2 -0
  28. package/dist/utils/blockTag.test.d.ts.map +1 -0
  29. package/dist/utils/blockTag.test.js +57 -0
  30. package/dist/utils/blockTag.test.js.map +1 -0
  31. package/dist/utils/gasEstimation.js +4 -4
  32. package/dist/utils/gasEstimation.js.map +1 -1
  33. package/dist/utils/gasEstimation.test.d.ts +2 -0
  34. package/dist/utils/gasEstimation.test.d.ts.map +1 -0
  35. package/dist/utils/gasEstimation.test.js +63 -0
  36. package/dist/utils/gasEstimation.test.js.map +1 -0
  37. package/dist/utils/tokenUtils.d.ts.map +1 -1
  38. package/dist/utils/tokenUtils.js +5 -2
  39. package/dist/utils/tokenUtils.js.map +1 -1
  40. package/package.json +7 -7
  41. package/src/bridges/LiFiBridge.test.ts +227 -0
  42. package/src/bridges/LiFiBridge.ts +82 -67
  43. package/src/config/RebalancerConfig.test.ts +135 -0
  44. package/src/config/types.ts +8 -0
  45. package/src/core/InventoryRebalancer.test.ts +160 -0
  46. package/src/core/InventoryRebalancer.ts +9 -3
  47. package/src/factories/RebalancerContextFactory.test.ts +116 -1
  48. package/src/factories/RebalancerContextFactory.ts +38 -28
  49. package/src/service.ts +11 -8
  50. package/src/utils/blockTag.test.ts +70 -0
  51. package/src/utils/blockTag.ts +11 -3
  52. package/src/utils/gasEstimation.test.ts +99 -0
  53. package/src/utils/gasEstimation.ts +4 -4
  54. package/src/utils/tokenUtils.ts +5 -2
@@ -572,6 +572,141 @@ describe('RebalancerConfig', () => {
572
572
  });
573
573
  });
574
574
 
575
+ describe('Tron inventorySigners validation', () => {
576
+ const TEST_CONFIG_PATH_TRON = join(tmpdir(), 'rebalancer-tron-test.yaml');
577
+
578
+ afterEach(() => {
579
+ rmSync(TEST_CONFIG_PATH_TRON, { force: true });
580
+ });
581
+
582
+ it('should reject Tron T-prefix base58 address in inventorySigners', () => {
583
+ const data: RebalancerConfigFileInput = {
584
+ warpRouteId: 'test-tron-route',
585
+ strategy: [
586
+ {
587
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
588
+ chains: {
589
+ tron: {
590
+ weighted: { weight: 50, tolerance: 5 },
591
+ executionType: ExecutionType.Inventory,
592
+ externalBridge: ExternalBridgeType.LiFi,
593
+ },
594
+ ethereum: {
595
+ weighted: { weight: 50, tolerance: 5 },
596
+ executionType: ExecutionType.Inventory,
597
+ externalBridge: ExternalBridgeType.LiFi,
598
+ },
599
+ },
600
+ },
601
+ ],
602
+ inventorySigners: {
603
+ tron: {
604
+ address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8',
605
+ key: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
606
+ },
607
+ ethereum: {
608
+ address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
609
+ key: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
610
+ },
611
+ },
612
+ externalBridges: {
613
+ lifi: {
614
+ integrator: 'test-app',
615
+ },
616
+ },
617
+ };
618
+
619
+ writeYamlOrJson(TEST_CONFIG_PATH_TRON, data);
620
+ expect(() => RebalancerConfig.load(TEST_CONFIG_PATH_TRON)).to.throw(
621
+ /must be a valid 0x hex address/,
622
+ );
623
+ });
624
+
625
+ it('should accept valid 0x hex address for Tron inventorySigners', () => {
626
+ const data: RebalancerConfigFileInput = {
627
+ warpRouteId: 'test-tron-route',
628
+ strategy: [
629
+ {
630
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
631
+ chains: {
632
+ tron: {
633
+ weighted: { weight: 50, tolerance: 5 },
634
+ executionType: ExecutionType.Inventory,
635
+ externalBridge: ExternalBridgeType.LiFi,
636
+ },
637
+ ethereum: {
638
+ weighted: { weight: 50, tolerance: 5 },
639
+ executionType: ExecutionType.Inventory,
640
+ externalBridge: ExternalBridgeType.LiFi,
641
+ },
642
+ },
643
+ },
644
+ ],
645
+ inventorySigners: {
646
+ tron: {
647
+ address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
648
+ key: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
649
+ },
650
+ ethereum: {
651
+ address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
652
+ key: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
653
+ },
654
+ },
655
+ externalBridges: {
656
+ lifi: {
657
+ integrator: 'test-app',
658
+ },
659
+ },
660
+ };
661
+
662
+ writeYamlOrJson(TEST_CONFIG_PATH_TRON, data);
663
+ expect(() => RebalancerConfig.load(TEST_CONFIG_PATH_TRON)).to.not.throw();
664
+ });
665
+
666
+ it('should reject invalid Tron address in inventorySigners', () => {
667
+ const data: RebalancerConfigFileInput = {
668
+ warpRouteId: 'test-tron-route',
669
+ strategy: [
670
+ {
671
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
672
+ chains: {
673
+ tron: {
674
+ weighted: { weight: 50, tolerance: 5 },
675
+ executionType: ExecutionType.Inventory,
676
+ externalBridge: ExternalBridgeType.LiFi,
677
+ },
678
+ ethereum: {
679
+ weighted: { weight: 50, tolerance: 5 },
680
+ executionType: ExecutionType.Inventory,
681
+ externalBridge: ExternalBridgeType.LiFi,
682
+ },
683
+ },
684
+ },
685
+ ],
686
+ inventorySigners: {
687
+ tron: {
688
+ address: 'INVALID_TRON_ADDRESS',
689
+ key: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
690
+ },
691
+ ethereum: {
692
+ address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
693
+ key: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
694
+ },
695
+ },
696
+ externalBridges: {
697
+ lifi: {
698
+ integrator: 'test-app',
699
+ },
700
+ },
701
+ };
702
+
703
+ writeYamlOrJson(TEST_CONFIG_PATH_TRON, data);
704
+ expect(() => RebalancerConfig.load(TEST_CONFIG_PATH_TRON)).to.throw(
705
+ /must be a valid 0x hex address/,
706
+ );
707
+ });
708
+ });
709
+
575
710
  describe('per-chain bridge configuration', () => {
576
711
  const TEST_CONFIG_PATH_BRIDGE = join(tmpdir(), 'rebalancer-bridge-test.yaml');
577
712
 
@@ -361,6 +361,14 @@ export const RebalancerConfigSchema = z
361
361
  path: ['inventorySigners', protocol],
362
362
  });
363
363
  }
364
+ } else if (protocol === ProtocolType.Tron) {
365
+ if (!isAddressEvm(signerConfig.address)) {
366
+ ctx.addIssue({
367
+ code: z.ZodIssueCode.custom,
368
+ message: `inventorySigners.${protocol} must be a valid 0x hex address, got: ${signerConfig.address}`,
369
+ path: ['inventorySigners', protocol],
370
+ });
371
+ }
364
372
  }
365
373
  // Other protocols: accept any non-empty string (future-proof)
366
374
  }
@@ -7,6 +7,7 @@ import Sinon, { type SinonStubbedInstance } from 'sinon';
7
7
 
8
8
  import {
9
9
  type ChainName,
10
+ HyperlaneCore,
10
11
  type MultiProvider,
11
12
  ProviderType,
12
13
  TokenStandard,
@@ -444,6 +445,165 @@ describe('InventoryRebalancer E2E', () => {
444
445
  });
445
446
  });
446
447
 
448
+ it('sendAndConfirmInventoryTx uses the EVM-like signer path for Tron txs', async () => {
449
+ const TRON_CHAIN = 'tron' as ChainName;
450
+ config.inventorySigners[ProtocolType.Tron] = {
451
+ address: INVENTORY_SIGNER,
452
+ key: TEST_PRIVATE_KEY,
453
+ };
454
+
455
+ const getChainMetadata = (chain: ChainName) => ({
456
+ name: chain,
457
+ protocol:
458
+ chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
459
+ blocks: { reorgPeriod: 2 },
460
+ rpcUrls: [{ http: 'http://127.0.0.1:9090/jsonrpc' }],
461
+ });
462
+ warpCore.multiProvider.getChainMetadata.callsFake(getChainMetadata);
463
+ multiProvider.getChainMetadata.callsFake(getChainMetadata);
464
+ warpCore.multiProvider.toMultiProvider =
465
+ Sinon.stub().returns(multiProvider);
466
+
467
+ const sendFn = (inventoryRebalancer as any).sendAndConfirmInventoryTx.bind(
468
+ inventoryRebalancer,
469
+ );
470
+ const result = await sendFn(TRON_CHAIN, {
471
+ category: WarpTxCategory.Transfer,
472
+ type: ProviderType.Tron,
473
+ transaction: {
474
+ to: '0xRouterAddress',
475
+ data: '0xTransferRemoteData',
476
+ value: 1000000n,
477
+ },
478
+ });
479
+
480
+ expect(result).to.deep.equal({ txHash: '0xTransferRemoteTxHash' });
481
+ expect(multiProvider.setSigner.calledOnce).to.be.true;
482
+ expect(
483
+ multiProvider.sendTransaction.calledOnceWithExactly(
484
+ TRON_CHAIN,
485
+ {
486
+ to: '0xRouterAddress',
487
+ data: '0xTransferRemoteData',
488
+ value: 1000000n,
489
+ },
490
+ { waitConfirmations: 2 },
491
+ ),
492
+ ).to.be.true;
493
+ });
494
+
495
+ it('buildSignerAccountConfig returns correct config for Tron protocol', () => {
496
+ config.inventorySigners[ProtocolType.Tron] = {
497
+ address: INVENTORY_SIGNER,
498
+ key: TEST_PRIVATE_KEY,
499
+ };
500
+
501
+ const buildFn = (inventoryRebalancer as any).buildSignerAccountConfig.bind(
502
+ inventoryRebalancer,
503
+ );
504
+ const result = buildFn(
505
+ ProtocolType.Tron,
506
+ TEST_PRIVATE_KEY,
507
+ 'tron' as ChainName,
508
+ );
509
+
510
+ expect(result.protocol).to.equal(ProtocolType.Tron);
511
+ expect(result.privateKey).to.equal(TEST_PRIVATE_KEY);
512
+ // Tron uses same 0x-prefixed hex key format as Ethereum
513
+ expect(result.privateKey.startsWith('0x')).to.be.true;
514
+ });
515
+
516
+ it('getTransactionReceipt returns EthersV5 type for Tron chain', async () => {
517
+ const TRON_CHAIN = 'tron' as ChainName;
518
+ const mockReceipt = {
519
+ transactionHash: '0xTronReceipt',
520
+ logs: [],
521
+ };
522
+
523
+ warpCore.multiProvider.getChainMetadata.callsFake((chain: ChainName) => ({
524
+ name: chain,
525
+ protocol:
526
+ chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
527
+ }));
528
+
529
+ warpCore.multiProvider.getEthersV5Provider.returns({
530
+ getTransactionReceipt: Sinon.stub().resolves(mockReceipt),
531
+ });
532
+
533
+ const getReceiptFn = (
534
+ inventoryRebalancer as any
535
+ ).getTransactionReceipt.bind(inventoryRebalancer);
536
+ const receipt = await getReceiptFn(TRON_CHAIN, '0xTronTxHash');
537
+
538
+ expect(receipt).to.not.be.undefined;
539
+ expect(receipt.type).to.equal(ProviderType.EthersV5);
540
+ expect(receipt.receipt).to.deep.equal(mockReceipt);
541
+ });
542
+
543
+ it('extractDispatchedMessageId returns Tron message ids via the EthersV5 path', async () => {
544
+ const TRON_CHAIN = 'tron' as ChainName;
545
+ const expectedMessageId =
546
+ '0x1111111111111111111111111111111111111111111111111111111111111111';
547
+ const mockReceipt = {
548
+ transactionHash: '0xTronTx',
549
+ logs: [{}],
550
+ };
551
+
552
+ warpCore.multiProvider.getChainMetadata.callsFake((chain: ChainName) => ({
553
+ name: chain,
554
+ protocol:
555
+ chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
556
+ }));
557
+
558
+ const getDispatchedMessagesStub = Sinon.stub(
559
+ HyperlaneCore,
560
+ 'getDispatchedMessages',
561
+ ).returns([{ id: expectedMessageId }] as any);
562
+ warpCore.multiProvider.getEthersV5Provider.returns({
563
+ getTransactionReceipt: Sinon.stub().resolves(mockReceipt),
564
+ });
565
+
566
+ const extractFn = (
567
+ inventoryRebalancer as any
568
+ ).extractDispatchedMessageId.bind(inventoryRebalancer);
569
+ const messageId = await extractFn(TRON_CHAIN, '0xTronTx');
570
+ expect(messageId).to.equal(expectedMessageId);
571
+ expect(getDispatchedMessagesStub.calledOnce).to.be.true;
572
+ expect(getDispatchedMessagesStub.firstCall.firstArg).to.equal(mockReceipt);
573
+ });
574
+
575
+ it('selects the Tron signer address from a mixed multi-protocol config', () => {
576
+ config.inventorySigners[ProtocolType.Tron] = {
577
+ address: INVENTORY_SIGNER,
578
+ key: TEST_PRIVATE_KEY,
579
+ };
580
+ config.inventorySigners[ProtocolType.Sealevel] = {
581
+ address: 'SoLANAAddReSs1234567890123456789012345678',
582
+ key: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32',
583
+ };
584
+
585
+ warpCore.multiProvider.getChainMetadata.callsFake((chain: ChainName) => ({
586
+ name: chain,
587
+ protocol:
588
+ chain === 'tron'
589
+ ? ProtocolType.Tron
590
+ : chain === SOLANA_CHAIN
591
+ ? ProtocolType.Sealevel
592
+ : ProtocolType.Ethereum,
593
+ }));
594
+
595
+ const getInventorySignerAddress = (
596
+ inventoryRebalancer as any
597
+ ).getInventorySignerAddress.bind(inventoryRebalancer);
598
+
599
+ expect(getInventorySignerAddress('tron' as ChainName)).to.equal(
600
+ INVENTORY_SIGNER,
601
+ );
602
+ expect(getInventorySignerAddress(SOLANA_CHAIN)).to.equal(
603
+ 'SoLANAAddReSs1234567890123456789012345678',
604
+ );
605
+ });
606
+
447
607
  describe('Partial Fulfillment (Insufficient Inventory)', () => {
448
608
  // Partial transfers happen when maxTransferable >= minViableTransfer.
449
609
  // For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so any
@@ -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 {
@@ -89,7 +95,6 @@ type InventoryMovementExecutionResult =
89
95
  success: false;
90
96
  error: string;
91
97
  };
92
-
93
98
  const RECOVERABLE_MAX_TRANSFER_ERROR_MESSAGES = [
94
99
  'balance may be insufficient',
95
100
  'transfer amount exceeds balance',
@@ -1221,6 +1226,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1221
1226
  ): MultiProtocolSignerSignerAccountInfo {
1222
1227
  void chain;
1223
1228
  switch (protocol) {
1229
+ case ProtocolType.Tron:
1224
1230
  case ProtocolType.Ethereum:
1225
1231
  return { protocol, privateKey: ensure0x(key) };
1226
1232
  case ProtocolType.Sealevel:
@@ -1260,7 +1266,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1260
1266
  try {
1261
1267
  const protocol = this.getProtocolForChain(origin);
1262
1268
 
1263
- if (protocol === ProtocolType.Ethereum) {
1269
+ if (isEVMLike(protocol)) {
1264
1270
  const provider =
1265
1271
  this.warpCore.multiProvider.getEthersV5Provider(origin);
1266
1272
  const receipt = await provider.getTransactionReceipt(txHash);
@@ -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
  };