@hyperlane-xyz/rebalancer 27.2.12 → 27.2.14

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 (97) hide show
  1. package/dist/bridges/LiFiBridge.js +1 -1
  2. package/dist/bridges/LiFiBridge.js.map +1 -1
  3. package/dist/bridges/LiFiBridge.test.js +37 -0
  4. package/dist/bridges/LiFiBridge.test.js.map +1 -1
  5. package/dist/core/InventoryRebalancer.d.ts +13 -19
  6. package/dist/core/InventoryRebalancer.d.ts.map +1 -1
  7. package/dist/core/InventoryRebalancer.js +400 -274
  8. package/dist/core/InventoryRebalancer.js.map +1 -1
  9. package/dist/core/InventoryRebalancer.test.js +706 -24
  10. package/dist/core/InventoryRebalancer.test.js.map +1 -1
  11. package/dist/core/Rebalancer.d.ts.map +1 -1
  12. package/dist/core/Rebalancer.js +12 -6
  13. package/dist/core/Rebalancer.js.map +1 -1
  14. package/dist/core/Rebalancer.test.js +51 -0
  15. package/dist/core/Rebalancer.test.js.map +1 -1
  16. package/dist/core/RebalancerOrchestrator.test.js +0 -1
  17. package/dist/core/RebalancerOrchestrator.test.js.map +1 -1
  18. package/dist/core/RebalancerService.d.ts +2 -3
  19. package/dist/core/RebalancerService.d.ts.map +1 -1
  20. package/dist/core/RebalancerService.js +3 -2
  21. package/dist/core/RebalancerService.js.map +1 -1
  22. package/dist/core/RebalancerService.test.js +24 -0
  23. package/dist/core/RebalancerService.test.js.map +1 -1
  24. package/dist/e2e/harness/TestHelpers.js +1 -2
  25. package/dist/e2e/harness/TestHelpers.js.map +1 -1
  26. package/dist/factories/RebalancerContextFactory.d.ts +4 -5
  27. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  28. package/dist/factories/RebalancerContextFactory.js +12 -7
  29. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  30. package/dist/factories/RebalancerContextFactory.test.js +99 -2
  31. package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
  32. package/dist/interfaces/IRebalancer.d.ts +4 -2
  33. package/dist/interfaces/IRebalancer.d.ts.map +1 -1
  34. package/dist/metrics/scripts/metrics.d.ts +1 -1
  35. package/dist/monitor/Monitor.d.ts.map +1 -1
  36. package/dist/monitor/Monitor.js +14 -6
  37. package/dist/monitor/Monitor.js.map +1 -1
  38. package/dist/strategy/BaseStrategy.d.ts.map +1 -1
  39. package/dist/strategy/BaseStrategy.js +13 -11
  40. package/dist/strategy/BaseStrategy.js.map +1 -1
  41. package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
  42. package/dist/strategy/CollateralDeficitStrategy.js +2 -2
  43. package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
  44. package/dist/strategy/MinAmountStrategy.d.ts +1 -0
  45. package/dist/strategy/MinAmountStrategy.d.ts.map +1 -1
  46. package/dist/strategy/MinAmountStrategy.js +12 -8
  47. package/dist/strategy/MinAmountStrategy.js.map +1 -1
  48. package/dist/strategy/MinAmountStrategy.test.js +189 -2
  49. package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
  50. package/dist/test/helpers.d.ts +11 -3
  51. package/dist/test/helpers.d.ts.map +1 -1
  52. package/dist/test/helpers.js +9 -11
  53. package/dist/test/helpers.js.map +1 -1
  54. package/dist/test/lifiMocks.d.ts.map +1 -1
  55. package/dist/test/lifiMocks.js +5 -2
  56. package/dist/test/lifiMocks.js.map +1 -1
  57. package/dist/tracking/ActionTracker.d.ts.map +1 -1
  58. package/dist/tracking/ActionTracker.js +2 -1
  59. package/dist/tracking/ActionTracker.js.map +1 -1
  60. package/dist/tracking/ActionTracker.test.js +39 -0
  61. package/dist/tracking/ActionTracker.test.js.map +1 -1
  62. package/dist/utils/balanceUtils.d.ts +7 -1
  63. package/dist/utils/balanceUtils.d.ts.map +1 -1
  64. package/dist/utils/balanceUtils.js +39 -1
  65. package/dist/utils/balanceUtils.js.map +1 -1
  66. package/dist/utils/balanceUtils.test.js +55 -1
  67. package/dist/utils/balanceUtils.test.js.map +1 -1
  68. package/dist/utils/blockTag.d.ts +3 -3
  69. package/dist/utils/blockTag.d.ts.map +1 -1
  70. package/dist/utils/blockTag.js +1 -1
  71. package/dist/utils/blockTag.js.map +1 -1
  72. package/package.json +7 -7
  73. package/src/bridges/LiFiBridge.test.ts +43 -0
  74. package/src/bridges/LiFiBridge.ts +1 -1
  75. package/src/core/InventoryRebalancer.test.ts +932 -38
  76. package/src/core/InventoryRebalancer.ts +579 -361
  77. package/src/core/Rebalancer.test.ts +84 -0
  78. package/src/core/Rebalancer.ts +22 -6
  79. package/src/core/RebalancerOrchestrator.test.ts +0 -1
  80. package/src/core/RebalancerService.test.ts +35 -0
  81. package/src/core/RebalancerService.ts +9 -5
  82. package/src/e2e/harness/TestHelpers.ts +3 -3
  83. package/src/factories/RebalancerContextFactory.test.ts +143 -6
  84. package/src/factories/RebalancerContextFactory.ts +29 -17
  85. package/src/interfaces/IRebalancer.ts +4 -1
  86. package/src/monitor/Monitor.ts +19 -6
  87. package/src/strategy/BaseStrategy.ts +18 -15
  88. package/src/strategy/CollateralDeficitStrategy.ts +4 -3
  89. package/src/strategy/MinAmountStrategy.test.ts +238 -2
  90. package/src/strategy/MinAmountStrategy.ts +29 -17
  91. package/src/test/helpers.ts +13 -12
  92. package/src/test/lifiMocks.ts +5 -2
  93. package/src/tracking/ActionTracker.test.ts +47 -0
  94. package/src/tracking/ActionTracker.ts +2 -1
  95. package/src/utils/balanceUtils.test.ts +87 -1
  96. package/src/utils/balanceUtils.ts +73 -2
  97. 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,199 @@ 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}`);
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
+ const quoteMode = source.maxTargetOutput > remaining ? 'reverse' : 'forward';
601
+ bridgePlans.push({
602
+ chain: source.chain,
603
+ maxSourceInput: source.maxSourceInput,
604
+ targetOutput,
605
+ quoteMode,
606
+ });
607
+ totalPlanned += targetOutput;
608
+ }
609
+ this.logger.info({
610
+ targetChain: destination,
611
+ viableSources: viableSources.map((s) => ({
612
+ chain: s.chain,
613
+ maxSourceInput: s.maxSourceInput.toString(),
614
+ maxTargetOutput: s.maxTargetOutput.toString(),
615
+ })),
616
+ bridgePlans: bridgePlans.map((p) => ({
617
+ chain: p.chain,
618
+ maxSourceInput: p.maxSourceInput.toString(),
619
+ targetOutput: p.targetOutput.toString(),
620
+ quoteMode: p.quoteMode,
621
+ })),
622
+ totalPlanned: totalPlanned.toString(),
623
+ shortfall: shortfall.toString(),
624
+ targetWithBuffer: targetWithBuffer.toString(),
625
+ intentId: intent.id,
626
+ }, 'Created bridge plans using gas-adjusted viable amounts');
627
+ // Execute all bridges in parallel
628
+ const bridgeResults = await Promise.allSettled(bridgePlans.map((plan) => this.executeInventoryMovement(plan.chain, destination, plan.targetOutput, plan.maxSourceInput, plan.quoteMode, intent, route.externalBridge)));
629
+ // Process results
630
+ let successCount = 0;
631
+ let totalQuotedOutputMin = 0n;
632
+ const failedErrors = [];
633
+ for (let i = 0; i < bridgeResults.length; i++) {
634
+ const result = bridgeResults[i];
635
+ const plan = bridgePlans[i];
636
+ if (result.status === 'fulfilled' && result.value.success) {
637
+ successCount++;
638
+ totalQuotedOutputMin += result.value.quotedOutputMin;
639
+ this.logger.info({
640
+ sourceChain: plan.chain,
641
+ plannedTargetOutput: plan.targetOutput.toString(),
642
+ quotedOutput: result.value.quotedOutput.toString(),
643
+ quotedOutputMin: result.value.quotedOutputMin.toString(),
644
+ quoteModeUsed: result.value.quoteModeUsed,
645
+ txHash: result.value.txHash,
646
+ }, 'Inventory movement succeeded');
647
+ }
648
+ else {
649
+ let error;
650
+ if (result.status === 'rejected') {
651
+ if (result.reason instanceof Error) {
652
+ error = result.reason.message;
653
+ }
654
+ else if (typeof result.reason === 'string') {
655
+ error = result.reason;
656
+ }
657
+ else {
658
+ try {
659
+ error = JSON.stringify(result.reason) ?? String(result.reason);
660
+ }
661
+ catch {
662
+ error = String(result.reason);
663
+ }
533
664
  }
534
- this.logger.warn({
535
- sourceChain: plan.chain,
536
- amount: plan.amount.toString(),
537
- error,
538
- }, 'Inventory movement failed');
539
665
  }
666
+ else if (!result.value.success) {
667
+ error = result.value.error;
668
+ }
669
+ if (error) {
670
+ failedErrors.push(`${plan.chain}: ${error}`);
671
+ }
672
+ this.logger.warn({
673
+ sourceChain: plan.chain,
674
+ plannedTargetOutput: plan.targetOutput.toString(),
675
+ error,
676
+ }, 'Inventory movement failed');
540
677
  }
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
- };
548
- }
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
678
  }
679
+ if (successCount === 0) {
680
+ const errorDetails = failedErrors.length > 0 ? ` (${failedErrors.join('; ')})` : '';
681
+ return {
682
+ route,
683
+ success: false,
684
+ error: `All inventory movements failed${errorDetails}`,
685
+ };
686
+ }
687
+ this.logger.info({
688
+ targetChain: destination,
689
+ successCount,
690
+ totalQuotedOutputMin: totalQuotedOutputMin.toString(),
691
+ targetAmount: requestedLocalAmount.toString(),
692
+ targetAmountCanonical: amount.toString(),
693
+ intentId: intent.id,
694
+ }, 'Parallel inventory movements completed, transferRemote will execute after bridges complete');
695
+ return { route, success: true };
558
696
  }
559
697
  /**
560
698
  * Execute a transferRemote to deposit collateral.
@@ -569,48 +707,21 @@ export class InventoryRebalancer {
569
707
  *
570
708
  * @param route - The transfer route (swapped direction)
571
709
  * @param intent - The rebalance intent being executed
572
- * @param gasQuote - Pre-calculated gas quote from calculateTransferCosts
573
710
  */
574
- async executeTransferRemote(route, intent, gasQuote) {
711
+ async executeTransferRemote(route, intent, fulfilledCanonicalAmount) {
575
712
  const { origin, destination, amount } = route;
576
713
  const originToken = this.getTokenForChain(origin);
577
714
  if (!originToken) {
578
715
  throw new Error(`No token found for origin chain: ${origin}`);
579
716
  }
580
717
  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
- }
718
+ this.logger.debug({ origin, destination, amount: amount.toString() }, 'Building transferRemote transactions for exact execution amount');
606
719
  const originTokenAmount = originToken.amount(amount);
607
720
  const transferTxs = await this.warpCore.getTransferRemoteTxs({
608
721
  originTokenAmount,
609
722
  destination,
610
723
  sender: this.getInventorySignerAddress(origin),
611
724
  recipient: this.getInventorySignerAddress(destination),
612
- interchainFee,
613
- tokenFeeQuote,
614
725
  });
615
726
  assert(transferTxs.length > 0, 'Expected at least one transaction from WarpCore');
616
727
  this.logger.info({
@@ -651,7 +762,7 @@ export class InventoryRebalancer {
651
762
  intentId: intent.id,
652
763
  origin: this.multiProvider.getDomainId(origin),
653
764
  destination: destinationDomain,
654
- amount,
765
+ amount: fulfilledCanonicalAmount,
655
766
  type: 'inventory_deposit',
656
767
  txHash: transferTxHash,
657
768
  messageId,
@@ -753,39 +864,26 @@ export class InventoryRebalancer {
753
864
  return sources.sort((a, b) => a.availableAmount > b.availableAmount ? -1 : 1);
754
865
  }
755
866
  /**
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
867
+ * Calculate the bridge capacity from a source chain in destination-local units.
868
+ * Uses LiFi quotes to conservatively estimate the destination output available
869
+ * from the source chain's current local inventory.
765
870
  *
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)
871
+ * For native-token sources, gas is reserved from the source inventory and the
872
+ * output capacity is re-quoted from the remaining source input.
771
873
  */
772
- async calculateMaxViableBridgeAmount(sourceChain, targetChain, rawInventory, externalBridgeType) {
874
+ async calculateBridgeCapacity(sourceChain, targetChain, rawInventory, externalBridgeType) {
773
875
  const sourceToken = this.getTokenForChain(sourceChain);
774
876
  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
- }
877
+ assert(sourceToken, `No token found for source chain: ${sourceChain}`);
878
+ assert(targetToken, `No token found for target chain: ${targetChain}`);
781
879
  // Convert HypNative token addresses to the external bridge's native token representation
782
- const fromTokenAddress = this.getNativeTokenAddress(externalBridgeType);
880
+ const fromTokenAddress = getExternalBridgeTokenAddress(sourceToken, externalBridgeType, this.getNativeTokenAddress.bind(this));
783
881
  const toTokenAddress = getExternalBridgeTokenAddress(targetToken, externalBridgeType, this.getNativeTokenAddress.bind(this));
784
882
  const sourceChainId = Number(this.multiProvider.getChainId(sourceChain));
785
883
  const targetChainId = Number(this.multiProvider.getChainId(targetChain));
786
884
  try {
787
885
  const externalBridge = this.getExternalBridge(externalBridgeType);
788
- const quote = await externalBridge.quote({
886
+ const initialQuote = await externalBridge.quote({
789
887
  fromChain: sourceChainId,
790
888
  toChain: targetChainId,
791
889
  fromToken: fromTokenAddress,
@@ -794,62 +892,74 @@ export class InventoryRebalancer {
794
892
  fromAddress: this.getInventorySignerAddress(sourceChain),
795
893
  toAddress: this.getInventorySignerAddress(targetChain),
796
894
  });
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;
895
+ let maxSourceInput = rawInventory;
896
+ let outputQuote = initialQuote;
897
+ if (isNativeTokenStandard(sourceToken.standard)) {
898
+ const estimatedGas = initialQuote.gasCosts * GAS_COST_MULTIPLIER;
899
+ const maxGasThreshold = rawInventory / MAX_GAS_PERCENT_THRESHOLD;
900
+ if (estimatedGas > maxGasThreshold) {
901
+ this.logger.info({
902
+ sourceChain,
903
+ targetChain,
904
+ rawInventory: rawInventory.toString(),
905
+ quotedGas: initialQuote.gasCosts.toString(),
906
+ estimatedGas: estimatedGas.toString(),
907
+ maxGasThreshold: maxGasThreshold.toString(),
908
+ }, 'Bridge not viable - gas cost exceeds 10% of inventory');
909
+ return { maxSourceInput: 0n, maxTargetOutput: 0n };
910
+ }
911
+ maxSourceInput = rawInventory - estimatedGas;
912
+ if (maxSourceInput <= 0n) {
913
+ return { maxSourceInput: 0n, maxTargetOutput: 0n };
914
+ }
915
+ outputQuote = await externalBridge.quote({
916
+ fromChain: sourceChainId,
917
+ toChain: targetChainId,
918
+ fromToken: fromTokenAddress,
919
+ toToken: toTokenAddress,
920
+ fromAmount: maxSourceInput,
921
+ fromAddress: this.getInventorySignerAddress(sourceChain),
922
+ toAddress: this.getInventorySignerAddress(targetChain),
923
+ });
814
924
  }
815
- // Max viable = inventory minus estimated gas
816
- const maxViable = rawInventory - estimatedGas;
817
925
  this.logger.info({
818
926
  sourceChain,
819
927
  targetChain,
820
928
  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;
929
+ maxSourceInput: maxSourceInput.toString(),
930
+ maxTargetOutput: outputQuote.toAmountMin.toString(),
931
+ }, 'Calculated bridge capacity');
932
+ return {
933
+ maxSourceInput,
934
+ maxTargetOutput: outputQuote.toAmountMin,
935
+ };
829
936
  }
830
937
  catch (error) {
831
938
  this.logger.warn({
832
939
  sourceChain,
833
940
  targetChain,
834
941
  error: error.message,
835
- }, 'Failed to calculate max viable bridge amount, skipping chain');
836
- return 0n;
942
+ }, 'Failed to calculate bridge capacity, skipping chain');
943
+ return { maxSourceInput: 0n, maxTargetOutput: 0n };
837
944
  }
838
945
  }
839
946
  /**
840
947
  * Execute inventory movement from source chain to target chain via LiFi bridge.
841
948
  *
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.
949
+ * Quote mode is chosen during planning:
950
+ * - `reverse`: request an exact target-chain output when the source has headroom
951
+ * - `forward`: spend the source cap directly when source inventory is the limiter
844
952
  *
845
953
  * @param sourceChain - Chain to move inventory from
846
954
  * @param targetChain - Chain to move inventory to (origin chain for rebalancing)
847
- * @param amount - Pre-validated amount to bridge (gas already accounted for)
955
+ * @param targetOutputAmount - Destination-local amount to receive
956
+ * @param maxSourceInput - Maximum source-local amount available for this plan
957
+ * @param quoteMode - Whether to execute this bridge plan as exact-input or exact-output
848
958
  * @param intent - Rebalance intent for tracking
849
959
  * @param externalBridgeType - External bridge type to use
850
960
  * @returns Result with success status and optional txHash/error
851
961
  */
852
- async executeInventoryMovement(sourceChain, targetChain, amount, intent, externalBridgeType) {
962
+ async executeInventoryMovement(sourceChain, targetChain, targetOutputAmount, maxSourceInput, quoteMode, intent, externalBridgeType) {
853
963
  const sourceToken = this.getTokenForChain(sourceChain);
854
964
  if (!sourceToken) {
855
965
  return {
@@ -878,59 +988,65 @@ export class InventoryRebalancer {
878
988
  fromTokenAddress,
879
989
  toTokenAddress,
880
990
  }, '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
991
  try {
906
992
  const externalBridge = this.getExternalBridge(externalBridgeType);
907
- const quote = await externalBridge.quote({
993
+ const fromAddress = this.getInventorySignerAddress(sourceChain);
994
+ const toAddress = this.getInventorySignerAddress(targetChain);
995
+ const quoteWithMode = async (mode) => externalBridge.quote({
908
996
  fromChain: sourceChainId,
909
997
  toChain: targetChainId,
910
998
  fromToken: fromTokenAddress,
911
999
  toToken: toTokenAddress,
912
- fromAmount: effectiveAmount,
913
- fromAddress: this.getInventorySignerAddress(sourceChain),
914
- toAddress: this.getInventorySignerAddress(targetChain),
1000
+ ...(mode === 'forward'
1001
+ ? { fromAmount: maxSourceInput }
1002
+ : { toAmount: targetOutputAmount }),
1003
+ fromAddress,
1004
+ toAddress,
915
1005
  });
1006
+ let quoteModeUsed = quoteMode;
1007
+ let quote = await quoteWithMode(quoteModeUsed);
1008
+ if (quoteModeUsed === 'reverse' && quote.fromAmount > maxSourceInput) {
1009
+ this.logger.warn({
1010
+ sourceChain,
1011
+ targetChain,
1012
+ plannedQuoteMode: quoteMode,
1013
+ requestedTargetOutput: targetOutputAmount.toString(),
1014
+ quotedInput: quote.fromAmount.toString(),
1015
+ maxSourceInput: maxSourceInput.toString(),
1016
+ intentId: intent.id,
1017
+ }, 'Reverse bridge quote exceeded source capacity, retrying with forward quote');
1018
+ // Spend the full source cap on fallback; minor output drift is acceptable
1019
+ // and will be reconciled by later cycles rather than risking livelock.
1020
+ quoteModeUsed = 'forward';
1021
+ quote = await quoteWithMode(quoteModeUsed);
1022
+ }
916
1023
  const inputRequired = quote.fromAmount;
1024
+ if (inputRequired > maxSourceInput) {
1025
+ return {
1026
+ success: false,
1027
+ error: `Bridge input ${inputRequired} exceeded planned source capacity ${maxSourceInput}`,
1028
+ };
1029
+ }
917
1030
  this.logger.info({
918
1031
  sourceChain,
919
1032
  targetChain,
920
1033
  sourceChainId,
921
1034
  targetChainId,
922
- preValidatedAmount: amount.toString(),
923
- preValidatedAmountEth: (Number(amount) / 1e18).toFixed(6),
924
- effectiveAmount: effectiveAmount.toString(),
925
- effectiveAmountEth: (Number(effectiveAmount) / 1e18).toFixed(6),
1035
+ requestedTargetOutput: targetOutputAmount.toString(),
1036
+ requestedTargetOutputFormatted: this.formatLocalAmount(targetOutputAmount, targetToken),
1037
+ quoteModePlanned: quoteMode,
1038
+ quoteModeUsed,
1039
+ retriedAsForward: quoteMode === 'reverse' && quoteModeUsed === 'forward',
926
1040
  inputRequired: inputRequired.toString(),
927
- expectedOutput: quote.toAmount.toString(),
928
- expectedOutputEth: (Number(quote.toAmount) / 1e18).toFixed(6),
1041
+ inputRequiredFormatted: this.formatLocalAmount(inputRequired, sourceToken),
1042
+ quotedOutput: quote.toAmount.toString(),
1043
+ quotedOutputMin: quote.toAmountMin.toString(),
1044
+ quotedOutputFormatted: this.formatLocalAmount(quote.toAmount, targetToken),
1045
+ quotedOutputMinFormatted: this.formatLocalAmount(quote.toAmountMin, targetToken),
929
1046
  gasCosts: quote.gasCosts.toString(),
930
1047
  feeCosts: quote.feeCosts.toString(),
931
1048
  intentId: intent.id,
932
- adjustedForMinViable: effectiveAmount > amount,
933
- }, 'Executing inventory movement via LiFi with pre-validated amount');
1049
+ }, 'Executing inventory movement via bridge quote');
934
1050
  this.logger.debug({
935
1051
  quoteId: quote.id,
936
1052
  tool: quote.tool,
@@ -957,6 +1073,8 @@ export class InventoryRebalancer {
957
1073
  txHash: result.txHash,
958
1074
  intentId: intent.id,
959
1075
  }, 'Inventory movement transaction executed');
1076
+ // Keep bridge consumption in source-local units; intent fulfillment only
1077
+ // advances from canonical inventory_deposit amounts after transferRemote.
960
1078
  await this.actionTracker.createRebalanceAction({
961
1079
  intentId: intent.id,
962
1080
  origin: this.multiProvider.getDomainId(sourceChain),
@@ -974,19 +1092,27 @@ export class InventoryRebalancer {
974
1092
  amountConsumed: inputRequired.toString(),
975
1093
  totalConsumed: (currentConsumed + inputRequired).toString(),
976
1094
  }, 'Updated consumed inventory after LiFi bridge');
977
- return { success: true, txHash: result.txHash };
1095
+ return {
1096
+ success: true,
1097
+ txHash: result.txHash,
1098
+ inputRequired,
1099
+ quotedOutput: quote.toAmount,
1100
+ quotedOutputMin: quote.toAmountMin,
1101
+ quoteModeUsed,
1102
+ };
978
1103
  }
979
1104
  catch (error) {
1105
+ const errorMessage = error instanceof Error ? error.message : String(error);
980
1106
  this.logger.error({
981
1107
  sourceChain,
982
1108
  targetChain,
983
- amount: amount.toString(),
1109
+ amount: targetOutputAmount.toString(),
984
1110
  intentId: intent.id,
985
- error: error.message,
1111
+ error: errorMessage,
986
1112
  }, 'Failed to execute inventory movement');
987
1113
  return {
988
1114
  success: false,
989
- error: error.message,
1115
+ error: errorMessage,
990
1116
  };
991
1117
  }
992
1118
  }