@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,6 +15,7 @@ const testLogger = pino({ level: 'silent' });
15
15
 
16
16
  const TEST_PRIVATE_KEY =
17
17
  '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
18
+ const OTHER_PRIVATE_KEY = `${TEST_PRIVATE_KEY.slice(0, -1)}1`;
18
19
 
19
20
  const BRIDGE_CONFIG: ExternalBridgeConfig = {
20
21
  integrator: 'test-rebalancer',
@@ -34,6 +35,58 @@ const SOLANA_CHAIN_METADATA_CONFIG: ExternalBridgeConfig = {
34
35
  },
35
36
  };
36
37
 
38
+ const DUPLICATE_CHAIN_ID_CONFIG: ExternalBridgeConfig = {
39
+ integrator: 'test-rebalancer',
40
+ chainMetadata: {
41
+ ethereum: {
42
+ chainId: 1,
43
+ protocol: ProtocolType.Ethereum,
44
+ name: 'ethereum',
45
+ displayName: 'Ethereum',
46
+ domainId: 1,
47
+ rpcUrls: [{ http: 'https://ethereum-rpc.local' }],
48
+ },
49
+ radix: {
50
+ chainId: 1,
51
+ protocol: ProtocolType.Radix,
52
+ name: 'radix',
53
+ displayName: 'Radix',
54
+ domainId: 1001,
55
+ rpcUrls: [{ http: 'https://radix-rpc.local' }],
56
+ },
57
+ solanamainnet: {
58
+ chainId: 1399811149,
59
+ protocol: ProtocolType.Sealevel,
60
+ name: 'solanamainnet',
61
+ displayName: 'Solana',
62
+ domainId: 1399811149,
63
+ rpcUrls: [{ http: 'https://api.mainnet-beta.solana.com' }],
64
+ },
65
+ },
66
+ };
67
+
68
+ const NON_EVM_DOMAIN_COLLISION_CONFIG: ExternalBridgeConfig = {
69
+ integrator: 'test-rebalancer',
70
+ chainMetadata: {
71
+ ethereum: {
72
+ chainId: 1,
73
+ protocol: ProtocolType.Ethereum,
74
+ name: 'ethereum',
75
+ displayName: 'Ethereum',
76
+ domainId: 1,
77
+ rpcUrls: [{ http: 'https://ethereum-rpc.local' }],
78
+ },
79
+ cosmos: {
80
+ chainId: 999999999,
81
+ protocol: ProtocolType.Cosmos,
82
+ name: 'cosmos',
83
+ displayName: 'Cosmos',
84
+ domainId: 1,
85
+ rpcUrls: [{ http: 'https://rpc.cosmos.invalid' }],
86
+ },
87
+ },
88
+ };
89
+
37
90
  // Use all-digit hex addresses to avoid EIP-55 checksum case mutations
38
91
  const TOKEN_ADDR = '0x1234567890123456789012345678901234567890';
39
92
  const SENDER_ADDR = '0x9876543210987654321098765432109876543210';
@@ -557,6 +610,49 @@ describe('LiFiBridge.quote() input validation', function () {
557
610
  });
558
611
  });
559
612
 
613
+ describe('LiFiBridge.quote() routing policy', function () {
614
+ let bridge: LiFiBridge;
615
+
616
+ beforeEach(() => {
617
+ bridge = new LiFiBridge(BRIDGE_CONFIG, testLogger);
618
+ });
619
+
620
+ it('should use RECOMMENDED order for reverse toAmount quotes', async () => {
621
+ const originalFetch = globalThis.fetch;
622
+ let requestUrl = '';
623
+
624
+ globalThis.fetch = (async (input: URL | RequestInfo) => {
625
+ requestUrl = String(input);
626
+ return {
627
+ ok: true,
628
+ json: async () =>
629
+ createLiFiStep({
630
+ toChainId: 1151111081099710,
631
+ toAmount: '5000000000',
632
+ }),
633
+ } as Response;
634
+ }) as typeof fetch;
635
+
636
+ try {
637
+ const quote = await bridge.quote({
638
+ fromChain: 42161,
639
+ toChain: 1151111081099710,
640
+ fromToken: TOKEN_ADDR,
641
+ toToken: TOKEN_ADDR,
642
+ fromAddress: SENDER_ADDR,
643
+ toAddress: SENDER_ADDR,
644
+ toAmount: 5000000000n,
645
+ });
646
+ const params = new URL(requestUrl).searchParams;
647
+
648
+ expect(params.get('order')).to.equal('RECOMMENDED');
649
+ expect(quote.requestParams.toAmount).to.equal(5000000000n);
650
+ } finally {
651
+ globalThis.fetch = originalFetch;
652
+ }
653
+ });
654
+ });
655
+
560
656
  describe('LiFiBridge.getStatus()', function () {
561
657
  let bridge: LiFiBridge;
562
658
 
@@ -612,4 +708,178 @@ describe('LiFiBridge constructor chainMetadataByChainId', function () {
612
708
  expect(msg).to.include('does not match requested');
613
709
  }
614
710
  });
711
+
712
+ it('should prefer Ethereum metadata for LiFi chainId lookups when non-EVM chainIds collide', () => {
713
+ const bridge = new LiFiBridge(DUPLICATE_CHAIN_ID_CONFIG, testLogger);
714
+ const quote = createTestQuote(
715
+ { fromChainId: 1 },
716
+ {
717
+ fromChain: 1,
718
+ toChain: 1151111081099710,
719
+ },
720
+ );
721
+
722
+ return bridge
723
+ .execute(quote, {
724
+ [ProtocolType.Ethereum]: '0x1234',
725
+ })
726
+ .catch((error: unknown) => {
727
+ const msg = error instanceof Error ? error.message : String(error);
728
+ expect(
729
+ isValidationError(msg),
730
+ `Expected non-validation error but got: ${msg}`,
731
+ ).to.equal(false);
732
+ expect(msg).to.not.include('Missing private key');
733
+ expect(msg).to.not.include('protocol radix');
734
+ expect(msg.toLowerCase()).to.include('private key');
735
+ });
736
+ });
737
+
738
+ it('should index Tron by chainId even when domainId differs', () => {
739
+ const TRON_CHAIN_ID = 728126428;
740
+ const bridge = new LiFiBridge(
741
+ {
742
+ integrator: 'test-rebalancer',
743
+ chainMetadata: {
744
+ tron: {
745
+ chainId: TRON_CHAIN_ID,
746
+ protocol: ProtocolType.Tron,
747
+ name: 'tron',
748
+ displayName: 'Tron',
749
+ domainId: 999000999,
750
+ rpcUrls: [{ http: 'https://api.trongrid.io/jsonrpc' }],
751
+ },
752
+ },
753
+ },
754
+ testLogger,
755
+ );
756
+ const getProtocolTypeForChainId = (
757
+ bridge as any
758
+ ).getProtocolTypeForChainId.bind(bridge);
759
+
760
+ expect(getProtocolTypeForChainId(TRON_CHAIN_ID)).to.equal(
761
+ ProtocolType.Tron,
762
+ );
763
+ expect(getProtocolTypeForChainId(999000999)).to.equal(undefined);
764
+ });
765
+
766
+ it('should not let non-EVM domainIds overwrite EVM chainId lookups', () => {
767
+ const bridge = new LiFiBridge(NON_EVM_DOMAIN_COLLISION_CONFIG, testLogger);
768
+ const getProtocolTypeForChainId = (
769
+ bridge as any
770
+ ).getProtocolTypeForChainId.bind(bridge);
771
+
772
+ expect(getProtocolTypeForChainId(1)).to.equal(ProtocolType.Ethereum);
773
+ expect(getProtocolTypeForChainId(999999999)).to.equal(ProtocolType.Cosmos);
774
+ });
775
+ });
776
+
777
+ describe('LiFiBridge source protocol handling', function () {
778
+ it('ignores unrelated Tron keys when executing an Ethereum LiFi route', async () => {
779
+ const bridge = new LiFiBridge(BRIDGE_CONFIG, testLogger);
780
+ (bridge as any).configureLiFiProvider = (
781
+ protocol: ProtocolType,
782
+ key: string,
783
+ fromChain: number,
784
+ ) => {
785
+ expect(protocol).to.equal(ProtocolType.Ethereum);
786
+ expect(key).to.equal(TEST_PRIVATE_KEY);
787
+ expect(fromChain).to.equal(42161);
788
+ throw new Error('expected downstream error');
789
+ };
790
+
791
+ const quote = createTestQuote();
792
+
793
+ try {
794
+ await bridge.execute(quote, {
795
+ [ProtocolType.Ethereum]: TEST_PRIVATE_KEY,
796
+ [ProtocolType.Tron]: OTHER_PRIVATE_KEY,
797
+ });
798
+ expect.fail('Expected execute to throw');
799
+ } catch (error: unknown) {
800
+ const msg = (error as Error).message;
801
+ expect(msg).to.equal('expected downstream error');
802
+ }
803
+ });
804
+
805
+ it('addressesEqual keeps Sealevel base58 comparison case-sensitive', () => {
806
+ const bridge = new LiFiBridge(SOLANA_CHAIN_METADATA_CONFIG, testLogger);
807
+ const addressesEqualFn = (bridge as any).addressesEqual.bind(bridge);
808
+
809
+ expect(
810
+ addressesEqualFn(
811
+ 'SoLANAAddReSs1234567890123456789012345678',
812
+ 'solanaaddress1234567890123456789012345678',
813
+ 1399811149,
814
+ ),
815
+ ).to.be.false;
816
+ });
817
+
818
+ it('throws when the route source protocol is Tron', async () => {
819
+ const TRON_CHAIN_ID = 728126428;
820
+ const bridge = new LiFiBridge(
821
+ {
822
+ integrator: 'test-rebalancer',
823
+ chainMetadata: {
824
+ tron: {
825
+ chainId: TRON_CHAIN_ID,
826
+ protocol: ProtocolType.Tron,
827
+ name: 'tron',
828
+ displayName: 'Tron',
829
+ domainId: TRON_CHAIN_ID,
830
+ rpcUrls: [{ http: 'https://api.trongrid.io/jsonrpc' }],
831
+ },
832
+ },
833
+ },
834
+ testLogger,
835
+ );
836
+ const quote = createTestQuote(
837
+ { fromChainId: TRON_CHAIN_ID },
838
+ { fromChain: TRON_CHAIN_ID },
839
+ );
840
+
841
+ try {
842
+ await bridge.execute(quote, {
843
+ [ProtocolType.Tron]: TEST_PRIVATE_KEY,
844
+ });
845
+ expect.fail('Should have thrown for unsupported source protocol Tron');
846
+ } catch (error: unknown) {
847
+ const msg = (error as Error).message;
848
+ expect(msg).to.include("Unsupported protocol type 'tron'");
849
+ }
850
+ });
851
+
852
+ it('throws when the route source protocol is an unsupported non-Tron chain', async () => {
853
+ const COSMOS_CHAIN_ID = 999999999;
854
+ const bridge = new LiFiBridge(
855
+ {
856
+ integrator: 'test-rebalancer',
857
+ chainMetadata: {
858
+ cosmos: {
859
+ chainId: COSMOS_CHAIN_ID,
860
+ protocol: ProtocolType.Cosmos,
861
+ name: 'cosmos',
862
+ displayName: 'Cosmos',
863
+ domainId: COSMOS_CHAIN_ID,
864
+ rpcUrls: [{ http: 'https://rpc.cosmos.invalid' }],
865
+ },
866
+ },
867
+ },
868
+ testLogger,
869
+ );
870
+ const quote = createTestQuote(
871
+ { fromChainId: COSMOS_CHAIN_ID },
872
+ { fromChain: COSMOS_CHAIN_ID },
873
+ );
874
+
875
+ try {
876
+ await bridge.execute(quote, {
877
+ [ProtocolType.Cosmos]: TEST_PRIVATE_KEY,
878
+ });
879
+ expect.fail('Should have thrown for unsupported source protocol Cosmos');
880
+ } catch (error: unknown) {
881
+ const msg = (error as Error).message;
882
+ expect(msg).to.include("Unsupported protocol type 'cosmos'");
883
+ }
884
+ });
615
885
  });
@@ -14,7 +14,12 @@ import {
14
14
  } from '@lifi/sdk';
15
15
  import bs58 from 'bs58';
16
16
  import type { ChainMetadata } from '@hyperlane-xyz/sdk';
17
- import { ProtocolType, assert, ensure0x } from '@hyperlane-xyz/utils';
17
+ import {
18
+ ProtocolType,
19
+ assert,
20
+ ensure0x,
21
+ isEVMLike,
22
+ } from '@hyperlane-xyz/utils';
18
23
  import type { Logger } from 'pino';
19
24
  import { type Chain, createWalletClient, http } from 'viem';
20
25
  import { privateKeyToAccount } from 'viem/accounts';
@@ -133,21 +138,25 @@ export class LiFiBridge implements IExternalBridge {
133
138
  constructor(config: ExternalBridgeConfig, logger: Logger) {
134
139
  this.config = config;
135
140
  this.logger = logger;
136
- // Build chainId -> metadata map for O(1) lookups
141
+ // Build LiFi chainId -> metadata map for O(1) lookups.
142
+ // Numeric chainIds are only unique for EVM chains; non-EVM chains can
143
+ // legitimately collide (e.g. radix and ethereum both use 1), so index
144
+ // non-EVM chains only through explicit Hyperlane-domain -> LiFi mappings.
137
145
  this.chainMetadataByChainId = new Map();
138
146
  if (config.chainMetadata) {
147
+ const metadataByDomainId = new Map<number, ChainMetadata>();
139
148
  for (const metadata of Object.values(config.chainMetadata)) {
140
- if (metadata.chainId !== undefined) {
149
+ metadataByDomainId.set(metadata.domainId, metadata);
150
+ if (metadata.chainId !== undefined && isEVMLike(metadata.protocol)) {
141
151
  this.chainMetadataByChainId.set(Number(metadata.chainId), metadata);
142
152
  }
143
153
  }
144
- // Also key by LiFi chain IDs so both Hyperlane domains and LiFi IDs resolve to the same metadata.
154
+ // Also key by LiFi chain IDs so both Hyperlane domains and LiFi IDs
155
+ // resolve to the same metadata for non-EVM chains like Solana.
145
156
  for (const [hyperlaneDomainId, lifiChainId] of Object.entries(
146
157
  HYPERLANE_TO_LIFI_CHAIN_IDS,
147
158
  )) {
148
- const metadata = this.chainMetadataByChainId.get(
149
- Number(hyperlaneDomainId),
150
- );
159
+ const metadata = metadataByDomainId.get(Number(hyperlaneDomainId));
151
160
  if (metadata !== undefined) {
152
161
  this.chainMetadataByChainId.set(Number(lifiChainId), metadata);
153
162
  }
@@ -179,11 +188,24 @@ export class LiFiBridge implements IExternalBridge {
179
188
  * Iterates metadata to find matching chainId and returns first HTTP RPC URL.
180
189
  */
181
190
  private getRpcUrlForChainId(chainId: number): string | undefined {
182
- return this.chainMetadataByChainId.get(chainId)?.rpcUrls?.[0]?.http;
191
+ return this.getMetadataForChainId(chainId)?.rpcUrls?.[0]?.http;
183
192
  }
184
193
 
185
194
  private getProtocolTypeForChainId(chainId: number): ProtocolType | undefined {
186
- return this.chainMetadataByChainId.get(chainId)?.protocol;
195
+ return this.getMetadataForChainId(chainId)?.protocol;
196
+ }
197
+
198
+ private getMetadataForChainId(chainId: number): ChainMetadata | undefined {
199
+ const directMetadata = this.chainMetadataByChainId.get(chainId);
200
+ if (directMetadata) {
201
+ return directMetadata;
202
+ }
203
+
204
+ const matches = Object.values(this.config.chainMetadata ?? {}).filter(
205
+ (metadata) =>
206
+ metadata.chainId !== undefined && Number(metadata.chainId) === chainId,
207
+ );
208
+ return matches.length === 1 ? matches[0] : undefined;
187
209
  }
188
210
 
189
211
  private addressesEqual(a: string, b: string, chainId: number): boolean {
@@ -196,58 +218,53 @@ export class LiFiBridge implements IExternalBridge {
196
218
  }
197
219
 
198
220
  /**
199
- * Configure LiFi SDK providers from the given private keys.
200
- * Sets up wallet/signer for each protocol type present in the keys map.
221
+ * Configure the LiFi SDK provider for the route source protocol.
201
222
  */
202
- private configureLiFiProviders(
203
- privateKeys: Partial<Record<ProtocolType, string>>,
223
+ private configureLiFiProvider(
224
+ protocol: ProtocolType,
225
+ key: string,
204
226
  fromChain: number,
205
227
  fromRpcUrl: string | undefined,
206
228
  ): void {
207
229
  const providers: Parameters<typeof lifiConfig.setProviders>[0] = [];
208
- for (const [protocol, key] of Object.entries(privateKeys)) {
209
- switch (protocol) {
210
- case ProtocolType.Ethereum: {
211
- const account = privateKeyToAccount(ensure0x(key) as `0x${string}`);
212
- const chain = getViemChain(fromChain, fromRpcUrl);
213
- const walletClient = createWalletClient({
214
- account,
215
- chain,
216
- transport: http(fromRpcUrl),
217
- });
218
- providers.push(
219
- EVM({
220
- getWalletClient: async () => walletClient,
221
- switchChain: async (requiredChainId: number) => {
222
- const switchRpcUrl = this.getRpcUrlForChainId(requiredChainId);
223
- const requiredChain = getViemChain(
224
- requiredChainId,
225
- switchRpcUrl,
226
- );
227
- return createWalletClient({
228
- account,
229
- chain: requiredChain,
230
- transport: http(switchRpcUrl),
231
- });
232
- },
233
- }),
234
- );
235
- break;
236
- }
237
- case ProtocolType.Sealevel: {
238
- const base58Key = toBase58SolanaKey(key);
239
- providers.push(
240
- Solana({
241
- getWalletAdapter: async () => new KeypairWalletAdapter(base58Key),
242
- }),
243
- );
244
- break;
245
- }
246
- default:
247
- throw new Error(
248
- `Unsupported protocol type '${protocol}' for LiFi provider`,
249
- );
230
+ switch (protocol) {
231
+ case ProtocolType.Ethereum: {
232
+ const account = privateKeyToAccount(ensure0x(key) as `0x${string}`);
233
+ const chain = getViemChain(fromChain, fromRpcUrl);
234
+ const walletClient = createWalletClient({
235
+ account,
236
+ chain,
237
+ transport: http(fromRpcUrl),
238
+ });
239
+ providers.push(
240
+ EVM({
241
+ getWalletClient: async () => walletClient,
242
+ switchChain: async (requiredChainId: number) => {
243
+ const switchRpcUrl = this.getRpcUrlForChainId(requiredChainId);
244
+ const requiredChain = getViemChain(requiredChainId, switchRpcUrl);
245
+ return createWalletClient({
246
+ account,
247
+ chain: requiredChain,
248
+ transport: http(switchRpcUrl),
249
+ });
250
+ },
251
+ }),
252
+ );
253
+ break;
254
+ }
255
+ case ProtocolType.Sealevel: {
256
+ const base58Key = toBase58SolanaKey(key);
257
+ providers.push(
258
+ Solana({
259
+ getWalletAdapter: async () => new KeypairWalletAdapter(base58Key),
260
+ }),
261
+ );
262
+ break;
250
263
  }
264
+ default:
265
+ throw new Error(
266
+ `Unsupported protocol type '${protocol}' for LiFi provider`,
267
+ );
251
268
  }
252
269
 
253
270
  lifiConfig.setProviders(providers);
@@ -255,9 +272,9 @@ export class LiFiBridge implements IExternalBridge {
255
272
  this.logger.debug(
256
273
  {
257
274
  fromChain,
258
- protocols: Object.keys(privateKeys),
275
+ protocol,
259
276
  },
260
- 'Configured LiFi providers for route execution',
277
+ 'Configured LiFi provider for route execution',
261
278
  );
262
279
  }
263
280
 
@@ -381,7 +398,7 @@ export class LiFiBridge implements IExternalBridge {
381
398
  slippage: (params.slippage ?? this.config.defaultSlippage ?? 0.005)
382
399
  .toFixed(4)
383
400
  .replace(/\.?0+$/, ''),
384
- order: 'CHEAPEST',
401
+ order: 'RECOMMENDED',
385
402
  integrator: this.config.integrator,
386
403
  });
387
404
 
@@ -491,9 +508,10 @@ export class LiFiBridge implements IExternalBridge {
491
508
  const fromChain = route.fromChainId;
492
509
  const toChain = route.toChainId;
493
510
  const fromProtocol = this.getProtocolTypeForChainId(fromChain);
511
+ const sourceProtocol = fromProtocol ?? ProtocolType.Ethereum;
494
512
  assert(
495
- privateKeys[fromProtocol ?? ProtocolType.Ethereum],
496
- `Missing private key for source chain protocol ${fromProtocol ?? ProtocolType.Ethereum}`,
513
+ privateKeys[sourceProtocol],
514
+ `Missing private key for source chain protocol ${sourceProtocol}`,
497
515
  );
498
516
 
499
517
  this.logger.info(
@@ -521,14 +539,11 @@ export class LiFiBridge implements IExternalBridge {
521
539
  let executedRoute!: RouteExtended;
522
540
 
523
541
  try {
524
- this.configureLiFiProviders(privateKeys, fromChain, fromRpcUrl);
525
-
526
- this.logger.debug(
527
- {
528
- fromChain,
529
- protocols: Object.keys(privateKeys),
530
- },
531
- 'Configured LiFi providers for route execution',
542
+ this.configureLiFiProvider(
543
+ sourceProtocol,
544
+ privateKeys[sourceProtocol]!,
545
+ fromChain,
546
+ fromRpcUrl,
532
547
  );
533
548
 
534
549
  // Execute route with update callbacks
@@ -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
  }