@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,9 +1,10 @@
1
- import { HyperlaneCore, Token, ProviderType, SealevelCoreAdapter, TOKEN_COLLATERALIZED_STANDARDS, TokenAmount, WarpTxCategory, getSignerForChain, } from '@hyperlane-xyz/sdk';
2
- import { ProtocolType, assert, ensure0x, isZeroishAddress, } from '@hyperlane-xyz/utils';
1
+ import { HyperlaneCore, ProviderType, SealevelCoreAdapter, TOKEN_COLLATERALIZED_STANDARDS, WarpTxCategory, getSignerForChain, } from '@hyperlane-xyz/sdk';
2
+ import { ProtocolType, assert, ensure0x, fromWei } from '@hyperlane-xyz/utils';
3
3
  import { MIN_VIABLE_COST_MULTIPLIER, calculateTransferCosts, } from '../utils/gasEstimation.js';
4
4
  import { getExternalBridgeTokenAddress, isNativeTokenStandard, } from '../utils/tokenUtils.js';
5
5
  import { parseSolanaPrivateKey } from '../utils/solanaKeyParser.js';
6
6
  import { toProtocolTransaction } from '../utils/transactionUtils.js';
7
+ import { alignLocalToCanonical, denormalizeToLocal, normalizeToCanonical, } from '../utils/balanceUtils.js';
7
8
  /**
8
9
  * Buffer percentage to add when bridging inventory.
9
10
  * Bridges (amount * (100 + BRIDGE_BUFFER_PERCENT)) / 100 to account for slippage.
@@ -21,6 +22,46 @@ const GAS_COST_MULTIPLIER = 20n;
21
22
  * If gas exceeds this threshold, the bridge is not economically worthwhile.
22
23
  */
23
24
  const MAX_GAS_PERCENT_THRESHOLD = 10n;
25
+ const RECOVERABLE_MAX_TRANSFER_ERROR_MESSAGES = [
26
+ 'balance may be insufficient',
27
+ 'transfer amount exceeds balance',
28
+ 'insufficient balance',
29
+ ];
30
+ function hasRecoverableMaxTransferErrorMessage(message) {
31
+ const normalized = message.toLowerCase();
32
+ return (normalized.includes('unpredictable_gas_limit') ||
33
+ RECOVERABLE_MAX_TRANSFER_ERROR_MESSAGES.some((pattern) => normalized.includes(pattern)));
34
+ }
35
+ function isRecoverableMaxTransferProbeError(error) {
36
+ const seen = new Set();
37
+ const stack = [error];
38
+ while (stack.length > 0) {
39
+ const current = stack.pop();
40
+ if (current == null)
41
+ continue;
42
+ if (typeof current === 'string') {
43
+ if (hasRecoverableMaxTransferErrorMessage(current))
44
+ return true;
45
+ continue;
46
+ }
47
+ if (typeof current !== 'object')
48
+ continue;
49
+ if (seen.has(current))
50
+ continue;
51
+ seen.add(current);
52
+ const candidate = current;
53
+ if (typeof candidate.code === 'string' &&
54
+ candidate.code.toUpperCase() === 'UNPREDICTABLE_GAS_LIMIT') {
55
+ return true;
56
+ }
57
+ if (typeof candidate.message === 'string' &&
58
+ hasRecoverableMaxTransferErrorMessage(candidate.message)) {
59
+ return true;
60
+ }
61
+ stack.push(candidate.cause, candidate.error);
62
+ }
63
+ return false;
64
+ }
24
65
  /**
25
66
  * Executes inventory-based rebalances for chains that don't support MovableCollateralRouter.
26
67
  *
@@ -169,6 +210,9 @@ export class InventoryRebalancer {
169
210
  }
170
211
  return total;
171
212
  }
213
+ formatLocalAmount(amount, token) {
214
+ return fromWei(amount.toString(), token.decimals);
215
+ }
172
216
  /**
173
217
  * Get the effective available inventory for a chain, accounting for
174
218
  * inventory already consumed during this execution cycle.
@@ -351,45 +395,96 @@ export class InventoryRebalancer {
351
395
  this.logger.info({
352
396
  strategyRoute: `${origin} (surplus) → ${destination} (deficit)`,
353
397
  executionRoute: `transferRemote FROM ${destination} TO ${origin}`,
354
- amount: amount.toString(),
398
+ canonicalAmount: amount.toString(),
355
399
  intentId: intent.id,
356
400
  }, 'Executing inventory route');
401
+ const sourceToken = this.getTokenForChain(destination);
402
+ assert(sourceToken, `No token found for source chain: ${destination}`);
403
+ const requestedLocalAmount = denormalizeToLocal(amount, sourceToken);
404
+ const executionSender = this.getInventorySignerAddress(destination);
405
+ const executionRecipient = this.getInventorySignerAddress(origin);
357
406
  // Check available inventory on the DESTINATION (deficit) chain
358
407
  // We need inventory here because transferRemote is called FROM this chain
359
408
  const availableInventory = this.getEffectiveAvailableInventory(destination);
360
409
  this.logger.info({
361
410
  checkingChain: destination,
362
411
  availableInventory: availableInventory.toString(),
363
- availableInventoryEth: (Number(availableInventory) / 1e18).toFixed(6),
364
- requiredAmount: amount.toString(),
365
- requiredAmountEth: (Number(amount) / 1e18).toFixed(6),
412
+ availableInventoryFormatted: this.formatLocalAmount(availableInventory, sourceToken),
413
+ requiredAmount: requestedLocalAmount.toString(),
414
+ requiredAmountFormatted: this.formatLocalAmount(requestedLocalAmount, sourceToken),
366
415
  }, 'Checking effective inventory on destination (deficit) chain');
367
416
  // Calculate transfer costs including max transferable and min viable amounts
368
417
  // transferRemote is called FROM destination TO origin (swapped direction)
369
418
  const costs = await calculateTransferCosts(destination, // FROM chain (where transferRemote is called)
370
419
  origin, // TO chain (where Hyperlane message goes)
371
- availableInventory, amount, this.multiProvider, this.warpCore.multiProvider, this.getTokenForChain.bind(this), this.getInventorySignerAddress(destination), isNativeTokenStandard, this.logger);
372
- const { maxTransferable, minViableTransfer } = costs;
420
+ availableInventory, requestedLocalAmount, this.multiProvider, this.warpCore.multiProvider, this.getTokenForChain.bind(this), executionSender, isNativeTokenStandard, this.logger);
421
+ const { minViableTransfer } = costs;
422
+ let maxTransferable = costs.maxTransferable;
423
+ if (!isNativeTokenStandard(sourceToken.standard)) {
424
+ if (availableInventory === 0n) {
425
+ maxTransferable = 0n;
426
+ this.logger.debug({
427
+ fromChain: destination,
428
+ toChain: origin,
429
+ requestedAmount: requestedLocalAmount.toString(),
430
+ }, 'Skipping fee-aware max transferable probe because destination inventory is zero');
431
+ }
432
+ else {
433
+ try {
434
+ const feeAwareMaxTransfer = await this.warpCore.getMaxTransferAmount({
435
+ balance: sourceToken.amount(availableInventory),
436
+ destination: origin,
437
+ sender: executionSender,
438
+ recipient: executionRecipient,
439
+ });
440
+ maxTransferable =
441
+ feeAwareMaxTransfer.amount < requestedLocalAmount
442
+ ? feeAwareMaxTransfer.amount
443
+ : requestedLocalAmount;
444
+ this.logger.debug({
445
+ fromChain: destination,
446
+ toChain: origin,
447
+ availableInventory: availableInventory.toString(),
448
+ requestedAmount: requestedLocalAmount.toString(),
449
+ feeAwareMaxTransferable: maxTransferable.toString(),
450
+ }, 'Calculated fee-aware max transferable amount for non-native route');
451
+ }
452
+ catch (error) {
453
+ if (!isRecoverableMaxTransferProbeError(error)) {
454
+ throw error;
455
+ }
456
+ maxTransferable = 0n;
457
+ this.logger.warn({
458
+ fromChain: destination,
459
+ toChain: origin,
460
+ availableInventory: availableInventory.toString(),
461
+ requestedAmount: requestedLocalAmount.toString(),
462
+ error: error instanceof Error ? error.message : String(error),
463
+ intentId: intent.id,
464
+ }, 'Fee-aware max transferable probe failed due to insufficient balance, falling back to external bridge');
465
+ }
466
+ }
467
+ }
373
468
  // Calculate total inventory across all chains
374
469
  // Note: consumedInventory tracking is handled separately within this cycle
375
470
  const totalInventory = this.getTotalInventory([]);
376
471
  this.logger.info({
377
472
  fromChain: destination,
378
473
  toChain: origin,
379
- availableInventoryEth: (Number(availableInventory) / 1e18).toFixed(6),
380
- requestedAmountEth: (Number(amount) / 1e18).toFixed(6),
381
- maxTransferableEth: (Number(maxTransferable) / 1e18).toFixed(6),
382
- minViableTransferEth: (Number(minViableTransfer) / 1e18).toFixed(6),
383
- totalInventoryEth: (Number(totalInventory) / 1e18).toFixed(6),
384
- canFullyFulfill: maxTransferable >= amount,
474
+ availableInventoryFormatted: this.formatLocalAmount(availableInventory, sourceToken),
475
+ requestedAmountFormatted: this.formatLocalAmount(requestedLocalAmount, sourceToken),
476
+ maxTransferableFormatted: this.formatLocalAmount(maxTransferable, sourceToken),
477
+ minViableTransferFormatted: this.formatLocalAmount(minViableTransfer, sourceToken),
478
+ canFullyFulfill: maxTransferable >= requestedLocalAmount,
385
479
  canPartialFulfill: maxTransferable >= minViableTransfer,
480
+ totalInventory: totalInventory.toString(),
386
481
  }, 'Calculated max transferable amount with cost-based threshold');
387
482
  // Early exit: If remaining amount is below minViableTransfer, complete the intent
388
483
  // This prevents infinite loops when the remaining amount is too small to economically bridge
389
- if (amount < minViableTransfer) {
484
+ if (requestedLocalAmount < minViableTransfer) {
390
485
  this.logger.info({
391
486
  intentId: intent.id,
392
- amount: amount.toString(),
487
+ amount: requestedLocalAmount.toString(),
393
488
  minViableTransfer: minViableTransfer.toString(),
394
489
  }, 'Remaining amount below minViableTransfer, completing intent with acceptable loss');
395
490
  await this.actionTracker.completeRebalanceIntent(intent.id);
@@ -405,156 +500,176 @@ export class InventoryRebalancer {
405
500
  ...route,
406
501
  origin: destination, // transferRemote called FROM here
407
502
  destination: origin, // Hyperlane message goes TO here
503
+ amount: requestedLocalAmount,
408
504
  };
409
- if (maxTransferable >= amount) {
505
+ if (maxTransferable >= requestedLocalAmount) {
410
506
  // Sufficient inventory on destination - execute transferRemote directly
411
- const result = await this.executeTransferRemote(swappedRoute, intent, costs.gasQuote);
507
+ const fulfilledCanonicalAmount = normalizeToCanonical(requestedLocalAmount, sourceToken);
508
+ const result = await this.executeTransferRemote(swappedRoute, intent, fulfilledCanonicalAmount);
412
509
  // Return original strategy route in result (not the swapped execution route)
413
510
  return { ...result, route };
414
511
  }
415
512
  else if (maxTransferable > 0n && maxTransferable >= minViableTransfer) {
416
513
  // Partial transfer: Transfer available inventory when economically viable
417
- const partialSwappedRoute = {
418
- ...swappedRoute,
419
- amount: maxTransferable,
420
- };
421
- const result = await this.executeTransferRemote(partialSwappedRoute, intent, costs.gasQuote);
422
- this.logger.info({
423
- intentId: intent.id,
424
- partialAmount: maxTransferable.toString(),
425
- requestedAmount: amount.toString(),
426
- remainingAmount: (amount - maxTransferable).toString(),
427
- }, 'Executed partial inventory deposit, remaining will be handled in future cycles');
428
- // Return original strategy route in result (not the swapped execution route)
429
- return { ...result, route };
430
- }
431
- else {
432
- // Inventory below cost-based threshold - trigger ExternalBridge movement TO destination chain
433
- this.logger.info({
434
- targetChain: destination,
435
- maxTransferable: maxTransferable.toString(),
436
- minViableTransfer: minViableTransfer.toString(),
437
- costMultiplier: MIN_VIABLE_COST_MULTIPLIER.toString(),
438
- intentId: intent.id,
439
- }, 'Inventory below cost-based threshold on destination, triggering LiFi movement');
440
- // Get all available source chains with raw inventory
441
- const allSources = this.selectAllSourceChains(destination);
442
- if (allSources.length === 0) {
443
- this.logger.warn({
444
- origin,
445
- destination,
446
- amount: amount.toString(),
514
+ const alignedExecution = alignLocalToCanonical(maxTransferable, sourceToken);
515
+ if (alignedExecution.messageAmount === 0n) {
516
+ this.logger.info({
447
517
  intentId: intent.id,
448
- }, 'No inventory available on any monitored chain');
449
- return {
450
- route,
451
- success: false,
452
- error: 'No inventory available on any monitored chain',
453
- };
518
+ maxTransferable: maxTransferable.toString(),
519
+ }, 'Skipping partial transferRemote because available local amount cannot produce canonical progress');
454
520
  }
455
- // NEW: Calculate max viable amount for each source chain
456
- // This uses the quote API to determine gas costs upfront
457
- const viableSources = [];
458
- for (const source of allSources) {
459
- const maxViable = await this.calculateMaxViableBridgeAmount(source.chain, destination, source.availableAmount, route.externalBridge);
460
- if (maxViable > 0n) {
461
- viableSources.push({ chain: source.chain, maxViable });
462
- }
463
- }
464
- // Sort by max viable descending (bridge from largest sources first)
465
- viableSources.sort((a, b) => (a.maxViable > b.maxViable ? -1 : 1));
466
- if (viableSources.length === 0) {
467
- this.logger.warn({
468
- targetChain: destination,
469
- sourcesChecked: allSources.length,
470
- intentId: intent.id,
471
- }, 'No viable bridge sources - all chains have insufficient inventory or high gas costs');
472
- return {
473
- route,
474
- success: false,
475
- error: 'No viable bridge sources available',
521
+ else {
522
+ const partialSwappedRoute = {
523
+ ...swappedRoute,
524
+ amount: alignedExecution.localAmount,
476
525
  };
526
+ const result = await this.executeTransferRemote(partialSwappedRoute, intent, alignedExecution.messageAmount);
527
+ this.logger.info({
528
+ intentId: intent.id,
529
+ partialAmount: alignedExecution.localAmount.toString(),
530
+ partialAmountCanonical: alignedExecution.messageAmount.toString(),
531
+ requestedAmount: requestedLocalAmount.toString(),
532
+ requestedAmountCanonical: amount.toString(),
533
+ remainingAmountCanonical: (amount > alignedExecution.messageAmount
534
+ ? amount - alignedExecution.messageAmount
535
+ : 0n).toString(),
536
+ }, 'Executed partial inventory deposit, remaining will be handled in future cycles');
537
+ // Return original strategy route in result (not the swapped execution route)
538
+ return { ...result, route };
477
539
  }
478
- // Create bridge plans using VIABLE amounts (gas already accounted for)
479
- const targetWithBuffer = ((amount + costs.totalCost) * (100n + BRIDGE_BUFFER_PERCENT)) / 100n;
480
- const bridgePlans = [];
481
- let totalPlanned = 0n;
482
- for (const source of viableSources) {
483
- if (totalPlanned >= targetWithBuffer)
484
- break;
485
- const remaining = targetWithBuffer - totalPlanned;
486
- const amountFromSource = source.maxViable >= remaining ? remaining : source.maxViable; // Already gas-adjusted!
487
- bridgePlans.push({
488
- chain: source.chain,
489
- amount: amountFromSource,
490
- });
491
- totalPlanned += amountFromSource;
540
+ }
541
+ // Inventory below cost-based threshold - trigger ExternalBridge movement TO destination chain
542
+ this.logger.info({
543
+ targetChain: destination,
544
+ maxTransferable: maxTransferable.toString(),
545
+ minViableTransfer: minViableTransfer.toString(),
546
+ costMultiplier: MIN_VIABLE_COST_MULTIPLIER.toString(),
547
+ intentId: intent.id,
548
+ }, 'Inventory below cost-based threshold on destination, triggering LiFi movement');
549
+ // Get all available source chains with raw inventory
550
+ const allSources = this.selectAllSourceChains(destination);
551
+ if (allSources.length === 0) {
552
+ this.logger.warn({
553
+ origin,
554
+ destination,
555
+ amount: requestedLocalAmount.toString(),
556
+ intentId: intent.id,
557
+ }, 'No inventory available on any monitored chain');
558
+ return {
559
+ route,
560
+ success: false,
561
+ error: 'No inventory available on any monitored chain',
562
+ };
563
+ }
564
+ // Calculate source capacities in destination-local units.
565
+ const viableSources = [];
566
+ for (const source of allSources) {
567
+ const capacity = await this.calculateBridgeCapacity(source.chain, destination, source.availableAmount, route.externalBridge);
568
+ if (capacity.maxTargetOutput > 0n) {
569
+ viableSources.push({ chain: source.chain, ...capacity });
492
570
  }
493
- this.logger.info({
571
+ }
572
+ // Sort by destination output descending.
573
+ viableSources.sort((a, b) => a.maxTargetOutput > b.maxTargetOutput ? -1 : 1);
574
+ if (viableSources.length === 0) {
575
+ this.logger.warn({
494
576
  targetChain: destination,
495
- viableSources: viableSources.map((s) => ({
496
- chain: s.chain,
497
- maxViable: s.maxViable.toString(),
498
- maxViableEth: (Number(s.maxViable) / 1e18).toFixed(6),
499
- })),
500
- bridgePlans: bridgePlans.map((p) => ({
501
- chain: p.chain,
502
- amount: p.amount.toString(),
503
- amountEth: (Number(p.amount) / 1e18).toFixed(6),
504
- })),
505
- totalPlanned: totalPlanned.toString(),
506
- targetWithBuffer: targetWithBuffer.toString(),
577
+ sourcesChecked: allSources.length,
507
578
  intentId: intent.id,
508
- }, 'Created bridge plans using gas-adjusted viable amounts');
509
- // Execute all bridges in parallel
510
- const bridgeResults = await Promise.allSettled(bridgePlans.map((plan) => this.executeInventoryMovement(plan.chain, destination, plan.amount, intent, route.externalBridge)));
511
- // Process results
512
- let successCount = 0;
513
- let totalBridged = 0n;
514
- const failedErrors = [];
515
- for (let i = 0; i < bridgeResults.length; i++) {
516
- const result = bridgeResults[i];
517
- const plan = bridgePlans[i];
518
- if (result.status === 'fulfilled' && result.value.success) {
519
- successCount++;
520
- totalBridged += plan.amount;
521
- this.logger.info({
522
- sourceChain: plan.chain,
523
- amount: plan.amount.toString(),
524
- txHash: result.value.txHash,
525
- }, 'Inventory movement succeeded');
526
- }
527
- else {
528
- const error = result.status === 'rejected'
529
- ? result.reason?.message
530
- : result.value.error;
531
- if (error) {
532
- failedErrors.push(`${plan.chain}: ${error}`);
533
- }
534
- this.logger.warn({
535
- sourceChain: plan.chain,
536
- amount: plan.amount.toString(),
537
- error,
538
- }, 'Inventory movement failed');
539
- }
579
+ }, 'No viable bridge sources - all chains have insufficient inventory or high gas costs');
580
+ return {
581
+ route,
582
+ success: false,
583
+ error: 'No viable bridge sources available',
584
+ };
585
+ }
586
+ // Create bridge plans using destination-local output amounts.
587
+ const shortfall = requestedLocalAmount > availableInventory
588
+ ? requestedLocalAmount - availableInventory
589
+ : 0n;
590
+ const targetWithBuffer = ((shortfall + costs.totalCost) * (100n + BRIDGE_BUFFER_PERCENT)) / 100n;
591
+ const bridgePlans = [];
592
+ let totalPlanned = 0n;
593
+ for (const source of viableSources) {
594
+ if (totalPlanned >= targetWithBuffer)
595
+ break;
596
+ const remaining = targetWithBuffer - totalPlanned;
597
+ const targetOutput = source.maxTargetOutput >= remaining
598
+ ? remaining
599
+ : source.maxTargetOutput;
600
+ bridgePlans.push({
601
+ chain: source.chain,
602
+ maxSourceInput: source.maxSourceInput,
603
+ targetOutput,
604
+ });
605
+ totalPlanned += targetOutput;
606
+ }
607
+ this.logger.info({
608
+ targetChain: destination,
609
+ viableSources: viableSources.map((s) => ({
610
+ chain: s.chain,
611
+ maxSourceInput: s.maxSourceInput.toString(),
612
+ maxTargetOutput: s.maxTargetOutput.toString(),
613
+ })),
614
+ bridgePlans: bridgePlans.map((p) => ({
615
+ chain: p.chain,
616
+ maxSourceInput: p.maxSourceInput.toString(),
617
+ targetOutput: p.targetOutput.toString(),
618
+ })),
619
+ totalPlanned: totalPlanned.toString(),
620
+ shortfall: shortfall.toString(),
621
+ targetWithBuffer: targetWithBuffer.toString(),
622
+ intentId: intent.id,
623
+ }, 'Created bridge plans using gas-adjusted viable amounts');
624
+ // Execute all bridges in parallel
625
+ const bridgeResults = await Promise.allSettled(bridgePlans.map((plan) => this.executeInventoryMovement(plan.chain, destination, plan.targetOutput, plan.maxSourceInput, intent, route.externalBridge)));
626
+ // Process results
627
+ let successCount = 0;
628
+ let totalBridged = 0n;
629
+ const failedErrors = [];
630
+ for (let i = 0; i < bridgeResults.length; i++) {
631
+ const result = bridgeResults[i];
632
+ const plan = bridgePlans[i];
633
+ if (result.status === 'fulfilled' && result.value.success) {
634
+ successCount++;
635
+ totalBridged += plan.targetOutput;
636
+ this.logger.info({
637
+ sourceChain: plan.chain,
638
+ amount: plan.targetOutput.toString(),
639
+ txHash: result.value.txHash,
640
+ }, 'Inventory movement succeeded');
540
641
  }
541
- if (successCount === 0) {
542
- const errorDetails = failedErrors.length > 0 ? ` (${failedErrors.join('; ')})` : '';
543
- return {
544
- route,
545
- success: false,
546
- error: `All inventory movements failed${errorDetails}`,
547
- };
642
+ else {
643
+ const error = result.status === 'rejected'
644
+ ? result.reason?.message
645
+ : result.value.error;
646
+ if (error) {
647
+ failedErrors.push(`${plan.chain}: ${error}`);
648
+ }
649
+ this.logger.warn({
650
+ sourceChain: plan.chain,
651
+ amount: plan.targetOutput.toString(),
652
+ error,
653
+ }, 'Inventory movement failed');
548
654
  }
549
- this.logger.info({
550
- targetChain: destination,
551
- successCount,
552
- totalBridged: totalBridged.toString(),
553
- targetAmount: amount.toString(),
554
- intentId: intent.id,
555
- }, 'Parallel inventory movements completed, transferRemote will execute after bridges complete');
556
- return { route, success: true };
557
655
  }
656
+ if (successCount === 0) {
657
+ const errorDetails = failedErrors.length > 0 ? ` (${failedErrors.join('; ')})` : '';
658
+ return {
659
+ route,
660
+ success: false,
661
+ error: `All inventory movements failed${errorDetails}`,
662
+ };
663
+ }
664
+ this.logger.info({
665
+ targetChain: destination,
666
+ successCount,
667
+ totalBridged: totalBridged.toString(),
668
+ targetAmount: requestedLocalAmount.toString(),
669
+ targetAmountCanonical: amount.toString(),
670
+ intentId: intent.id,
671
+ }, 'Parallel inventory movements completed, transferRemote will execute after bridges complete');
672
+ return { route, success: true };
558
673
  }
559
674
  /**
560
675
  * Execute a transferRemote to deposit collateral.
@@ -569,48 +684,21 @@ export class InventoryRebalancer {
569
684
  *
570
685
  * @param route - The transfer route (swapped direction)
571
686
  * @param intent - The rebalance intent being executed
572
- * @param gasQuote - Pre-calculated gas quote from calculateTransferCosts
573
687
  */
574
- async executeTransferRemote(route, intent, gasQuote) {
688
+ async executeTransferRemote(route, intent, fulfilledCanonicalAmount) {
575
689
  const { origin, destination, amount } = route;
576
690
  const originToken = this.getTokenForChain(origin);
577
691
  if (!originToken) {
578
692
  throw new Error(`No token found for origin chain: ${origin}`);
579
693
  }
580
694
  const destinationDomain = this.multiProvider.getDomainId(destination);
581
- this.logger.debug({
582
- origin,
583
- destination,
584
- amount: amount.toString(),
585
- gasQuote: {
586
- igpQuote: gasQuote.igpQuote.amount.toString(),
587
- tokenFeeQuote: gasQuote.tokenFeeQuote?.amount?.toString() ?? 'none',
588
- },
589
- }, 'Using pre-calculated gas quote for transferRemote');
590
- // Convert pre-calculated gas quote to TokenAmount for WarpCore
591
- const originChainMetadata = this.multiProvider.getChainMetadata(origin);
592
- const igpAddressOrDenom = gasQuote.igpQuote.addressOrDenom;
593
- const igpToken = !igpAddressOrDenom || isZeroishAddress(igpAddressOrDenom)
594
- ? Token.FromChainMetadataNativeToken(originChainMetadata)
595
- : this.warpCore.findToken(origin, igpAddressOrDenom);
596
- assert(igpToken, `IGP fee token ${igpAddressOrDenom} is unknown`);
597
- const interchainFee = new TokenAmount(gasQuote.igpQuote.amount, igpToken);
598
- let tokenFeeQuote;
599
- if (gasQuote.tokenFeeQuote?.amount) {
600
- const feeAddress = gasQuote.tokenFeeQuote.addressOrDenom;
601
- const feeToken = !feeAddress || isZeroishAddress(feeAddress)
602
- ? Token.FromChainMetadataNativeToken(originChainMetadata)
603
- : originToken;
604
- tokenFeeQuote = new TokenAmount(gasQuote.tokenFeeQuote.amount, feeToken);
605
- }
695
+ this.logger.debug({ origin, destination, amount: amount.toString() }, 'Building transferRemote transactions for exact execution amount');
606
696
  const originTokenAmount = originToken.amount(amount);
607
697
  const transferTxs = await this.warpCore.getTransferRemoteTxs({
608
698
  originTokenAmount,
609
699
  destination,
610
700
  sender: this.getInventorySignerAddress(origin),
611
701
  recipient: this.getInventorySignerAddress(destination),
612
- interchainFee,
613
- tokenFeeQuote,
614
702
  });
615
703
  assert(transferTxs.length > 0, 'Expected at least one transaction from WarpCore');
616
704
  this.logger.info({
@@ -651,7 +739,7 @@ export class InventoryRebalancer {
651
739
  intentId: intent.id,
652
740
  origin: this.multiProvider.getDomainId(origin),
653
741
  destination: destinationDomain,
654
- amount,
742
+ amount: fulfilledCanonicalAmount,
655
743
  type: 'inventory_deposit',
656
744
  txHash: transferTxHash,
657
745
  messageId,
@@ -753,39 +841,26 @@ export class InventoryRebalancer {
753
841
  return sources.sort((a, b) => a.availableAmount > b.availableAmount ? -1 : 1);
754
842
  }
755
843
  /**
756
- * Calculate the maximum amount that can be bridged from a source chain.
757
- * Uses LiFi quote to determine gas costs, applies 20x multiplier buffer.
758
- * Returns 0 if gas exceeds 10% of inventory (not economically viable).
759
- *
760
- * This is the key method for the gas-aware planning approach:
761
- * - Gets a quote for the full raw inventory to determine actual gas costs
762
- * - Applies conservative 20x buffer (LiFi underestimates by ~14x historically)
763
- * - Returns 0 if gas > 10% of inventory (not worth bridging)
764
- * - Returns inventory - estimatedGas if viable
844
+ * Calculate the bridge capacity from a source chain in destination-local units.
845
+ * Uses LiFi quotes to conservatively estimate the destination output available
846
+ * from the source chain's current local inventory.
765
847
  *
766
- * @param sourceChain - Chain to bridge from
767
- * @param targetChain - Chain to bridge to
768
- * @param rawInventory - Raw available inventory on source chain
769
- * @param externalBridgeType - External bridge type to use
770
- * @returns Maximum viable bridge amount (0 if not viable)
848
+ * For native-token sources, gas is reserved from the source inventory and the
849
+ * output capacity is re-quoted from the remaining source input.
771
850
  */
772
- async calculateMaxViableBridgeAmount(sourceChain, targetChain, rawInventory, externalBridgeType) {
851
+ async calculateBridgeCapacity(sourceChain, targetChain, rawInventory, externalBridgeType) {
773
852
  const sourceToken = this.getTokenForChain(sourceChain);
774
853
  const targetToken = this.getTokenForChain(targetChain);
775
- if (!sourceToken || !targetToken)
776
- return 0n;
777
- // Only applies to native tokens (need gas from same balance)
778
- if (!isNativeTokenStandard(sourceToken.standard)) {
779
- return rawInventory; // ERC20s don't compete with gas
780
- }
854
+ assert(sourceToken, `No token found for source chain: ${sourceChain}`);
855
+ assert(targetToken, `No token found for target chain: ${targetChain}`);
781
856
  // Convert HypNative token addresses to the external bridge's native token representation
782
- const fromTokenAddress = this.getNativeTokenAddress(externalBridgeType);
857
+ const fromTokenAddress = getExternalBridgeTokenAddress(sourceToken, externalBridgeType, this.getNativeTokenAddress.bind(this));
783
858
  const toTokenAddress = getExternalBridgeTokenAddress(targetToken, externalBridgeType, this.getNativeTokenAddress.bind(this));
784
859
  const sourceChainId = Number(this.multiProvider.getChainId(sourceChain));
785
860
  const targetChainId = Number(this.multiProvider.getChainId(targetChain));
786
861
  try {
787
862
  const externalBridge = this.getExternalBridge(externalBridgeType);
788
- const quote = await externalBridge.quote({
863
+ const initialQuote = await externalBridge.quote({
789
864
  fromChain: sourceChainId,
790
865
  toChain: targetChainId,
791
866
  fromToken: fromTokenAddress,
@@ -794,62 +869,72 @@ export class InventoryRebalancer {
794
869
  fromAddress: this.getInventorySignerAddress(sourceChain),
795
870
  toAddress: this.getInventorySignerAddress(targetChain),
796
871
  });
797
- // Apply 20x multiplier on quoted gas (LiFi underestimates by ~14x)
798
- const estimatedGas = quote.gasCosts * GAS_COST_MULTIPLIER;
799
- // Viability check: gas should not exceed 10% of inventory
800
- const maxGasThreshold = rawInventory / MAX_GAS_PERCENT_THRESHOLD;
801
- if (estimatedGas > maxGasThreshold) {
802
- this.logger.info({
803
- sourceChain,
804
- targetChain,
805
- rawInventory: rawInventory.toString(),
806
- rawInventoryEth: (Number(rawInventory) / 1e18).toFixed(6),
807
- quotedGas: quote.gasCosts.toString(),
808
- estimatedGas: estimatedGas.toString(),
809
- estimatedGasEth: (Number(estimatedGas) / 1e18).toFixed(6),
810
- maxGasThreshold: maxGasThreshold.toString(),
811
- gasPercent: `${(Number(estimatedGas) * 100) / Number(rawInventory)}%`,
812
- }, 'Bridge not viable - gas cost exceeds 10% of inventory');
813
- return 0n;
872
+ let maxSourceInput = rawInventory;
873
+ let outputQuote = initialQuote;
874
+ if (isNativeTokenStandard(sourceToken.standard)) {
875
+ const estimatedGas = initialQuote.gasCosts * GAS_COST_MULTIPLIER;
876
+ const maxGasThreshold = rawInventory / MAX_GAS_PERCENT_THRESHOLD;
877
+ if (estimatedGas > maxGasThreshold) {
878
+ this.logger.info({
879
+ sourceChain,
880
+ targetChain,
881
+ rawInventory: rawInventory.toString(),
882
+ quotedGas: initialQuote.gasCosts.toString(),
883
+ estimatedGas: estimatedGas.toString(),
884
+ maxGasThreshold: maxGasThreshold.toString(),
885
+ }, 'Bridge not viable - gas cost exceeds 10% of inventory');
886
+ return { maxSourceInput: 0n, maxTargetOutput: 0n };
887
+ }
888
+ maxSourceInput = rawInventory - estimatedGas;
889
+ if (maxSourceInput <= 0n) {
890
+ return { maxSourceInput: 0n, maxTargetOutput: 0n };
891
+ }
892
+ outputQuote = await externalBridge.quote({
893
+ fromChain: sourceChainId,
894
+ toChain: targetChainId,
895
+ fromToken: fromTokenAddress,
896
+ toToken: toTokenAddress,
897
+ fromAmount: maxSourceInput,
898
+ fromAddress: this.getInventorySignerAddress(sourceChain),
899
+ toAddress: this.getInventorySignerAddress(targetChain),
900
+ });
814
901
  }
815
- // Max viable = inventory minus estimated gas
816
- const maxViable = rawInventory - estimatedGas;
817
902
  this.logger.info({
818
903
  sourceChain,
819
904
  targetChain,
820
905
  rawInventory: rawInventory.toString(),
821
- rawInventoryEth: (Number(rawInventory) / 1e18).toFixed(6),
822
- quotedGas: quote.gasCosts.toString(),
823
- estimatedGas: estimatedGas.toString(),
824
- estimatedGasEth: (Number(estimatedGas) / 1e18).toFixed(6),
825
- maxViable: maxViable.toString(),
826
- maxViableEth: (Number(maxViable) / 1e18).toFixed(6),
827
- }, 'Calculated max viable bridge amount');
828
- return maxViable;
906
+ maxSourceInput: maxSourceInput.toString(),
907
+ maxTargetOutput: outputQuote.toAmountMin.toString(),
908
+ }, 'Calculated bridge capacity');
909
+ return {
910
+ maxSourceInput,
911
+ maxTargetOutput: outputQuote.toAmountMin,
912
+ };
829
913
  }
830
914
  catch (error) {
831
915
  this.logger.warn({
832
916
  sourceChain,
833
917
  targetChain,
834
918
  error: error.message,
835
- }, 'Failed to calculate max viable bridge amount, skipping chain');
836
- return 0n;
919
+ }, 'Failed to calculate bridge capacity, skipping chain');
920
+ return { maxSourceInput: 0n, maxTargetOutput: 0n };
837
921
  }
838
922
  }
839
923
  /**
840
924
  * Execute inventory movement from source chain to target chain via LiFi bridge.
841
925
  *
842
- * IMPORTANT: The amount parameter is now the MAX VIABLE amount (gas already subtracted
843
- * by calculateMaxViableBridgeAmount). This method trusts that the amount is pre-validated.
926
+ * Uses reverse quotes (`toAmount`) so plans are expressed in target-chain local
927
+ * units and source-local spend is discovered by the bridge quote.
844
928
  *
845
929
  * @param sourceChain - Chain to move inventory from
846
930
  * @param targetChain - Chain to move inventory to (origin chain for rebalancing)
847
- * @param amount - Pre-validated amount to bridge (gas already accounted for)
931
+ * @param targetOutputAmount - Destination-local amount to receive
932
+ * @param maxSourceInput - Maximum source-local amount available for this plan
848
933
  * @param intent - Rebalance intent for tracking
849
934
  * @param externalBridgeType - External bridge type to use
850
935
  * @returns Result with success status and optional txHash/error
851
936
  */
852
- async executeInventoryMovement(sourceChain, targetChain, amount, intent, externalBridgeType) {
937
+ async executeInventoryMovement(sourceChain, targetChain, targetOutputAmount, maxSourceInput, intent, externalBridgeType) {
853
938
  const sourceToken = this.getTokenForChain(sourceChain);
854
939
  if (!sourceToken) {
855
940
  return {
@@ -878,30 +963,6 @@ export class InventoryRebalancer {
878
963
  fromTokenAddress,
879
964
  toTokenAddress,
880
965
  }, 'Resolved token addresses for LiFi bridge');
881
- // Calculate minViableTransfer for the target chain
882
- // If bridging less than this, the received amount won't be enough to execute transferRemote
883
- // So we over-bridge to ensure we can complete the intent in the next cycle
884
- const costs = await calculateTransferCosts(targetChain, // FROM chain for transferRemote (the target of this bridge)
885
- sourceChain, // TO chain for transferRemote (Hyperlane message destination)
886
- amount, // availableInventory (not used for minViableTransfer calculation)
887
- amount, // requestedAmount
888
- this.multiProvider, this.warpCore.multiProvider, this.getTokenForChain.bind(this), this.getInventorySignerAddress(targetChain), isNativeTokenStandard, this.logger);
889
- const { minViableTransfer } = costs;
890
- // If the requested amount is below minViableTransfer, adjust it up
891
- // This ensures we bridge enough to actually complete the final transferRemote
892
- const effectiveAmount = amount < minViableTransfer ? minViableTransfer : amount;
893
- if (effectiveAmount !== amount) {
894
- this.logger.info({
895
- originalAmount: amount.toString(),
896
- effectiveAmount: effectiveAmount.toString(),
897
- minViableTransfer: minViableTransfer.toString(),
898
- originalAmountEth: (Number(amount) / 1e18).toFixed(6),
899
- effectiveAmountEth: (Number(effectiveAmount) / 1e18).toFixed(6),
900
- minViableTransferEth: (Number(minViableTransfer) / 1e18).toFixed(6),
901
- adjustedUp: true,
902
- intentId: intent.id,
903
- }, 'Over-bridging to minViableTransfer to ensure final transferRemote can complete');
904
- }
905
966
  try {
906
967
  const externalBridge = this.getExternalBridge(externalBridgeType);
907
968
  const quote = await externalBridge.quote({
@@ -909,28 +970,33 @@ export class InventoryRebalancer {
909
970
  toChain: targetChainId,
910
971
  fromToken: fromTokenAddress,
911
972
  toToken: toTokenAddress,
912
- fromAmount: effectiveAmount,
973
+ toAmount: targetOutputAmount,
913
974
  fromAddress: this.getInventorySignerAddress(sourceChain),
914
975
  toAddress: this.getInventorySignerAddress(targetChain),
915
976
  });
916
977
  const inputRequired = quote.fromAmount;
978
+ if (inputRequired > maxSourceInput) {
979
+ return {
980
+ success: false,
981
+ error: `Bridge input ${inputRequired} exceeded planned source capacity ${maxSourceInput}`,
982
+ };
983
+ }
917
984
  this.logger.info({
918
985
  sourceChain,
919
986
  targetChain,
920
987
  sourceChainId,
921
988
  targetChainId,
922
- preValidatedAmount: amount.toString(),
923
- preValidatedAmountEth: (Number(amount) / 1e18).toFixed(6),
924
- effectiveAmount: effectiveAmount.toString(),
925
- effectiveAmountEth: (Number(effectiveAmount) / 1e18).toFixed(6),
989
+ requestedTargetOutput: targetOutputAmount.toString(),
990
+ requestedTargetOutputFormatted: this.formatLocalAmount(targetOutputAmount, targetToken),
926
991
  inputRequired: inputRequired.toString(),
992
+ inputRequiredFormatted: this.formatLocalAmount(inputRequired, sourceToken),
927
993
  expectedOutput: quote.toAmount.toString(),
928
- expectedOutputEth: (Number(quote.toAmount) / 1e18).toFixed(6),
994
+ expectedOutputMin: quote.toAmountMin.toString(),
995
+ expectedOutputFormatted: this.formatLocalAmount(quote.toAmount, targetToken),
929
996
  gasCosts: quote.gasCosts.toString(),
930
997
  feeCosts: quote.feeCosts.toString(),
931
998
  intentId: intent.id,
932
- adjustedForMinViable: effectiveAmount > amount,
933
- }, 'Executing inventory movement via LiFi with pre-validated amount');
999
+ }, 'Executing inventory movement via LiFi reverse quote');
934
1000
  this.logger.debug({
935
1001
  quoteId: quote.id,
936
1002
  tool: quote.tool,
@@ -957,6 +1023,8 @@ export class InventoryRebalancer {
957
1023
  txHash: result.txHash,
958
1024
  intentId: intent.id,
959
1025
  }, 'Inventory movement transaction executed');
1026
+ // Keep bridge consumption in source-local units; intent fulfillment only
1027
+ // advances from canonical inventory_deposit amounts after transferRemote.
960
1028
  await this.actionTracker.createRebalanceAction({
961
1029
  intentId: intent.id,
962
1030
  origin: this.multiProvider.getDomainId(sourceChain),
@@ -980,7 +1048,7 @@ export class InventoryRebalancer {
980
1048
  this.logger.error({
981
1049
  sourceChain,
982
1050
  targetChain,
983
- amount: amount.toString(),
1051
+ amount: targetOutputAmount.toString(),
984
1052
  intentId: intent.id,
985
1053
  error: error.message,
986
1054
  }, 'Failed to execute inventory movement');