@hyperlane-xyz/rebalancer 27.2.13 → 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.
@@ -74,6 +74,22 @@ type BridgeCapacity = {
74
74
  maxTargetOutput: bigint;
75
75
  };
76
76
 
77
+ type BridgeQuoteMode = 'forward' | 'reverse';
78
+
79
+ type InventoryMovementExecutionResult =
80
+ | {
81
+ success: true;
82
+ txHash: string;
83
+ inputRequired: bigint;
84
+ quotedOutput: bigint;
85
+ quotedOutputMin: bigint;
86
+ quoteModeUsed: BridgeQuoteMode;
87
+ }
88
+ | {
89
+ success: false;
90
+ error: string;
91
+ };
92
+
77
93
  const RECOVERABLE_MAX_TRANSFER_ERROR_MESSAGES = [
78
94
  'balance may be insufficient',
79
95
  'transfer amount exceeds balance',
@@ -910,6 +926,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
910
926
  chain: ChainName;
911
927
  maxSourceInput: bigint;
912
928
  targetOutput: bigint;
929
+ quoteMode: BridgeQuoteMode;
913
930
  }> = [];
914
931
  let totalPlanned = 0n;
915
932
 
@@ -921,11 +938,14 @@ export class InventoryRebalancer implements IInventoryRebalancer {
921
938
  source.maxTargetOutput >= remaining
922
939
  ? remaining
923
940
  : source.maxTargetOutput;
941
+ const quoteMode: BridgeQuoteMode =
942
+ source.maxTargetOutput > remaining ? 'reverse' : 'forward';
924
943
 
925
944
  bridgePlans.push({
926
945
  chain: source.chain,
927
946
  maxSourceInput: source.maxSourceInput,
928
947
  targetOutput,
948
+ quoteMode,
929
949
  });
930
950
  totalPlanned += targetOutput;
931
951
  }
@@ -942,6 +962,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
942
962
  chain: p.chain,
943
963
  maxSourceInput: p.maxSourceInput.toString(),
944
964
  targetOutput: p.targetOutput.toString(),
965
+ quoteMode: p.quoteMode,
945
966
  })),
946
967
  totalPlanned: totalPlanned.toString(),
947
968
  shortfall: shortfall.toString(),
@@ -959,6 +980,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
959
980
  destination,
960
981
  plan.targetOutput,
961
982
  plan.maxSourceInput,
983
+ plan.quoteMode,
962
984
  intent,
963
985
  route.externalBridge,
964
986
  ),
@@ -967,7 +989,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
967
989
 
968
990
  // Process results
969
991
  let successCount = 0;
970
- let totalBridged = 0n;
992
+ let totalQuotedOutputMin = 0n;
971
993
  const failedErrors: string[] = [];
972
994
 
973
995
  for (let i = 0; i < bridgeResults.length; i++) {
@@ -976,27 +998,42 @@ export class InventoryRebalancer implements IInventoryRebalancer {
976
998
 
977
999
  if (result.status === 'fulfilled' && result.value.success) {
978
1000
  successCount++;
979
- totalBridged += plan.targetOutput;
1001
+ totalQuotedOutputMin += result.value.quotedOutputMin;
980
1002
  this.logger.info(
981
1003
  {
982
1004
  sourceChain: plan.chain,
983
- amount: plan.targetOutput.toString(),
1005
+ plannedTargetOutput: plan.targetOutput.toString(),
1006
+ quotedOutput: result.value.quotedOutput.toString(),
1007
+ quotedOutputMin: result.value.quotedOutputMin.toString(),
1008
+ quoteModeUsed: result.value.quoteModeUsed,
984
1009
  txHash: result.value.txHash,
985
1010
  },
986
1011
  'Inventory movement succeeded',
987
1012
  );
988
1013
  } else {
989
- const error =
990
- result.status === 'rejected'
991
- ? result.reason?.message
992
- : result.value.error;
1014
+ let error: string | undefined;
1015
+ if (result.status === 'rejected') {
1016
+ if (result.reason instanceof Error) {
1017
+ error = result.reason.message;
1018
+ } else if (typeof result.reason === 'string') {
1019
+ error = result.reason;
1020
+ } else {
1021
+ try {
1022
+ error = JSON.stringify(result.reason) ?? String(result.reason);
1023
+ } catch {
1024
+ error = String(result.reason);
1025
+ }
1026
+ }
1027
+ } else if (!result.value.success) {
1028
+ error = result.value.error;
1029
+ }
993
1030
  if (error) {
994
1031
  failedErrors.push(`${plan.chain}: ${error}`);
995
1032
  }
996
1033
  this.logger.warn(
997
1034
  {
998
1035
  sourceChain: plan.chain,
999
- amount: plan.targetOutput.toString(),
1036
+ plannedTargetOutput: plan.targetOutput.toString(),
1000
1037
  error,
1001
1038
  },
1002
1039
  'Inventory movement failed',
@@ -1018,7 +1055,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1018
1055
  {
1019
1056
  targetChain: destination,
1020
1057
  successCount,
1021
- totalBridged: totalBridged.toString(),
1058
+ totalQuotedOutputMin: totalQuotedOutputMin.toString(),
1022
1059
  targetAmount: requestedLocalAmount.toString(),
1023
1060
  targetAmountCanonical: amount.toString(),
1024
1061
  intentId: intent.id,
@@ -1395,13 +1432,15 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1395
1432
  /**
1396
1433
  * Execute inventory movement from source chain to target chain via LiFi bridge.
1397
1434
  *
1398
- * Uses reverse quotes (`toAmount`) so plans are expressed in target-chain local
1399
- * units and source-local spend is discovered by the bridge quote.
1435
+ * Quote mode is chosen during planning:
1436
+ * - `reverse`: request an exact target-chain output when the source has headroom
1437
+ * - `forward`: spend the source cap directly when source inventory is the limiter
1400
1438
  *
1401
1439
  * @param sourceChain - Chain to move inventory from
1402
1440
  * @param targetChain - Chain to move inventory to (origin chain for rebalancing)
1403
1441
  * @param targetOutputAmount - Destination-local amount to receive
1404
1442
  * @param maxSourceInput - Maximum source-local amount available for this plan
1443
+ * @param quoteMode - Whether to execute this bridge plan as exact-input or exact-output
1405
1444
  * @param intent - Rebalance intent for tracking
1406
1445
  * @param externalBridgeType - External bridge type to use
1407
1446
  * @returns Result with success status and optional txHash/error
@@ -1411,9 +1450,10 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1411
1450
  targetChain: ChainName,
1412
1451
  targetOutputAmount: bigint,
1413
1452
  maxSourceInput: bigint,
1453
+ quoteMode: BridgeQuoteMode,
1414
1454
  intent: RebalanceIntent,
1415
1455
  externalBridgeType: ExternalBridgeType,
1416
- ): Promise<{ success: boolean; txHash?: string; error?: string }> {
1456
+ ): Promise<InventoryMovementExecutionResult> {
1417
1457
  const sourceToken = this.getTokenForChain(sourceChain);
1418
1458
  if (!sourceToken) {
1419
1459
  return {
@@ -1461,15 +1501,43 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1461
1501
 
1462
1502
  try {
1463
1503
  const externalBridge = this.getExternalBridge(externalBridgeType);
1464
- const quote = await externalBridge.quote({
1465
- fromChain: sourceChainId,
1466
- toChain: targetChainId,
1467
- fromToken: fromTokenAddress,
1468
- toToken: toTokenAddress,
1469
- toAmount: targetOutputAmount,
1470
- fromAddress: this.getInventorySignerAddress(sourceChain),
1471
- toAddress: this.getInventorySignerAddress(targetChain),
1472
- });
1504
+ const fromAddress = this.getInventorySignerAddress(sourceChain);
1505
+ const toAddress = this.getInventorySignerAddress(targetChain);
1506
+ const quoteWithMode = async (mode: BridgeQuoteMode) =>
1507
+ externalBridge.quote({
1508
+ fromChain: sourceChainId,
1509
+ toChain: targetChainId,
1510
+ fromToken: fromTokenAddress,
1511
+ toToken: toTokenAddress,
1512
+ ...(mode === 'forward'
1513
+ ? { fromAmount: maxSourceInput }
1514
+ : { toAmount: targetOutputAmount }),
1515
+ fromAddress,
1516
+ toAddress,
1517
+ });
1518
+
1519
+ let quoteModeUsed = quoteMode;
1520
+ let quote = await quoteWithMode(quoteModeUsed);
1521
+
1522
+ if (quoteModeUsed === 'reverse' && quote.fromAmount > maxSourceInput) {
1523
+ this.logger.warn(
1524
+ {
1525
+ sourceChain,
1526
+ targetChain,
1527
+ plannedQuoteMode: quoteMode,
1528
+ requestedTargetOutput: targetOutputAmount.toString(),
1529
+ quotedInput: quote.fromAmount.toString(),
1530
+ maxSourceInput: maxSourceInput.toString(),
1531
+ intentId: intent.id,
1532
+ },
1533
+ 'Reverse bridge quote exceeded source capacity, retrying with forward quote',
1534
+ );
1535
+
1536
+ // Spend the full source cap on fallback; minor output drift is acceptable
1537
+ // and will be reconciled by later cycles rather than risking livelock.
1538
+ quoteModeUsed = 'forward';
1539
+ quote = await quoteWithMode(quoteModeUsed);
1540
+ }
1473
1541
 
1474
1542
  const inputRequired = quote.fromAmount;
1475
1543
  if (inputRequired > maxSourceInput) {
@@ -1490,22 +1558,30 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1490
1558
  targetOutputAmount,
1491
1559
  targetToken,
1492
1560
  ),
1561
+ quoteModePlanned: quoteMode,
1562
+ quoteModeUsed,
1563
+ retriedAsForward:
1564
+ quoteMode === 'reverse' && quoteModeUsed === 'forward',
1493
1565
  inputRequired: inputRequired.toString(),
1494
1566
  inputRequiredFormatted: this.formatLocalAmount(
1495
1567
  inputRequired,
1496
1568
  sourceToken,
1497
1569
  ),
1498
- expectedOutput: quote.toAmount.toString(),
1499
- expectedOutputMin: quote.toAmountMin.toString(),
1500
- expectedOutputFormatted: this.formatLocalAmount(
1570
+ quotedOutput: quote.toAmount.toString(),
1571
+ quotedOutputMin: quote.toAmountMin.toString(),
1572
+ quotedOutputFormatted: this.formatLocalAmount(
1501
1573
  quote.toAmount,
1502
1574
  targetToken,
1503
1575
  ),
1576
+ quotedOutputMinFormatted: this.formatLocalAmount(
1577
+ quote.toAmountMin,
1578
+ targetToken,
1579
+ ),
1504
1580
  gasCosts: quote.gasCosts.toString(),
1505
1581
  feeCosts: quote.feeCosts.toString(),
1506
1582
  intentId: intent.id,
1507
1583
  },
1508
- 'Executing inventory movement via LiFi reverse quote',
1584
+ 'Executing inventory movement via bridge quote',
1509
1585
  );
1510
1586
 
1511
1587
  this.logger.debug(
@@ -1573,22 +1649,31 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1573
1649
  'Updated consumed inventory after LiFi bridge',
1574
1650
  );
1575
1651
 
1576
- return { success: true, txHash: result.txHash };
1652
+ return {
1653
+ success: true,
1654
+ txHash: result.txHash,
1655
+ inputRequired,
1656
+ quotedOutput: quote.toAmount,
1657
+ quotedOutputMin: quote.toAmountMin,
1658
+ quoteModeUsed,
1659
+ };
1577
1660
  } catch (error) {
1661
+ const errorMessage =
1662
+ error instanceof Error ? error.message : String(error);
1578
1663
  this.logger.error(
1579
1664
  {
1580
1665
  sourceChain,
1581
1666
  targetChain,
1582
1667
  amount: targetOutputAmount.toString(),
1583
1668
  intentId: intent.id,
1584
- error: (error as Error).message,
1669
+ error: errorMessage,
1585
1670
  },
1586
1671
  'Failed to execute inventory movement',
1587
1672
  );
1588
1673
 
1589
1674
  return {
1590
1675
  success: false,
1591
- error: (error as Error).message,
1676
+ error: errorMessage,
1592
1677
  };
1593
1678
  }
1594
1679
  }