@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.
- package/dist/bridges/LiFiBridge.js +1 -1
- package/dist/bridges/LiFiBridge.js.map +1 -1
- package/dist/bridges/LiFiBridge.test.js +37 -0
- package/dist/bridges/LiFiBridge.test.js.map +1 -1
- package/dist/core/InventoryRebalancer.d.ts +13 -19
- package/dist/core/InventoryRebalancer.d.ts.map +1 -1
- package/dist/core/InventoryRebalancer.js +400 -274
- package/dist/core/InventoryRebalancer.js.map +1 -1
- package/dist/core/InventoryRebalancer.test.js +706 -24
- package/dist/core/InventoryRebalancer.test.js.map +1 -1
- package/dist/core/Rebalancer.d.ts.map +1 -1
- package/dist/core/Rebalancer.js +12 -6
- package/dist/core/Rebalancer.js.map +1 -1
- package/dist/core/Rebalancer.test.js +51 -0
- package/dist/core/Rebalancer.test.js.map +1 -1
- package/dist/core/RebalancerOrchestrator.test.js +0 -1
- package/dist/core/RebalancerOrchestrator.test.js.map +1 -1
- package/dist/core/RebalancerService.d.ts +2 -3
- package/dist/core/RebalancerService.d.ts.map +1 -1
- package/dist/core/RebalancerService.js +3 -2
- package/dist/core/RebalancerService.js.map +1 -1
- package/dist/core/RebalancerService.test.js +24 -0
- package/dist/core/RebalancerService.test.js.map +1 -1
- package/dist/e2e/harness/TestHelpers.js +1 -2
- package/dist/e2e/harness/TestHelpers.js.map +1 -1
- package/dist/factories/RebalancerContextFactory.d.ts +4 -5
- package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
- package/dist/factories/RebalancerContextFactory.js +12 -7
- package/dist/factories/RebalancerContextFactory.js.map +1 -1
- package/dist/factories/RebalancerContextFactory.test.js +99 -2
- package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
- package/dist/interfaces/IRebalancer.d.ts +4 -2
- package/dist/interfaces/IRebalancer.d.ts.map +1 -1
- package/dist/metrics/scripts/metrics.d.ts +1 -1
- package/dist/monitor/Monitor.d.ts.map +1 -1
- package/dist/monitor/Monitor.js +14 -6
- package/dist/monitor/Monitor.js.map +1 -1
- package/dist/strategy/BaseStrategy.d.ts.map +1 -1
- package/dist/strategy/BaseStrategy.js +13 -11
- package/dist/strategy/BaseStrategy.js.map +1 -1
- package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
- package/dist/strategy/CollateralDeficitStrategy.js +2 -2
- package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
- package/dist/strategy/MinAmountStrategy.d.ts +1 -0
- package/dist/strategy/MinAmountStrategy.d.ts.map +1 -1
- package/dist/strategy/MinAmountStrategy.js +12 -8
- package/dist/strategy/MinAmountStrategy.js.map +1 -1
- package/dist/strategy/MinAmountStrategy.test.js +189 -2
- package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
- package/dist/test/helpers.d.ts +11 -3
- package/dist/test/helpers.d.ts.map +1 -1
- package/dist/test/helpers.js +9 -11
- package/dist/test/helpers.js.map +1 -1
- package/dist/test/lifiMocks.d.ts.map +1 -1
- package/dist/test/lifiMocks.js +5 -2
- package/dist/test/lifiMocks.js.map +1 -1
- package/dist/tracking/ActionTracker.d.ts.map +1 -1
- package/dist/tracking/ActionTracker.js +2 -1
- package/dist/tracking/ActionTracker.js.map +1 -1
- package/dist/tracking/ActionTracker.test.js +39 -0
- package/dist/tracking/ActionTracker.test.js.map +1 -1
- package/dist/utils/balanceUtils.d.ts +7 -1
- package/dist/utils/balanceUtils.d.ts.map +1 -1
- package/dist/utils/balanceUtils.js +39 -1
- package/dist/utils/balanceUtils.js.map +1 -1
- package/dist/utils/balanceUtils.test.js +55 -1
- package/dist/utils/balanceUtils.test.js.map +1 -1
- package/dist/utils/blockTag.d.ts +3 -3
- package/dist/utils/blockTag.d.ts.map +1 -1
- package/dist/utils/blockTag.js +1 -1
- package/dist/utils/blockTag.js.map +1 -1
- package/package.json +7 -7
- package/src/bridges/LiFiBridge.test.ts +43 -0
- package/src/bridges/LiFiBridge.ts +1 -1
- package/src/core/InventoryRebalancer.test.ts +932 -38
- package/src/core/InventoryRebalancer.ts +579 -361
- package/src/core/Rebalancer.test.ts +84 -0
- package/src/core/Rebalancer.ts +22 -6
- package/src/core/RebalancerOrchestrator.test.ts +0 -1
- package/src/core/RebalancerService.test.ts +35 -0
- package/src/core/RebalancerService.ts +9 -5
- package/src/e2e/harness/TestHelpers.ts +3 -3
- package/src/factories/RebalancerContextFactory.test.ts +143 -6
- package/src/factories/RebalancerContextFactory.ts +29 -17
- package/src/interfaces/IRebalancer.ts +4 -1
- package/src/monitor/Monitor.ts +19 -6
- package/src/strategy/BaseStrategy.ts +18 -15
- package/src/strategy/CollateralDeficitStrategy.ts +4 -3
- package/src/strategy/MinAmountStrategy.test.ts +238 -2
- package/src/strategy/MinAmountStrategy.ts +29 -17
- package/src/test/helpers.ts +13 -12
- package/src/test/lifiMocks.ts +5 -2
- package/src/tracking/ActionTracker.test.ts +47 -0
- package/src/tracking/ActionTracker.ts +2 -1
- package/src/utils/balanceUtils.test.ts +87 -1
- package/src/utils/balanceUtils.ts +73 -2
- package/src/utils/blockTag.ts +9 -4
|
@@ -3,27 +3,19 @@ import type { Logger } from 'pino';
|
|
|
3
3
|
import {
|
|
4
4
|
type ChainName,
|
|
5
5
|
HyperlaneCore,
|
|
6
|
-
type InterchainGasQuote,
|
|
7
|
-
type IToken,
|
|
8
6
|
type MultiProtocolSignerSignerAccountInfo,
|
|
9
7
|
type MultiProvider,
|
|
10
|
-
Token,
|
|
11
8
|
ProviderType,
|
|
12
9
|
SealevelCoreAdapter,
|
|
13
10
|
TOKEN_COLLATERALIZED_STANDARDS,
|
|
14
|
-
|
|
11
|
+
type Token,
|
|
15
12
|
type WarpTypedTransaction,
|
|
16
13
|
type WarpCore,
|
|
17
14
|
WarpTxCategory,
|
|
18
15
|
getSignerForChain,
|
|
19
16
|
type TypedTransactionReceipt,
|
|
20
17
|
} from '@hyperlane-xyz/sdk';
|
|
21
|
-
import {
|
|
22
|
-
ProtocolType,
|
|
23
|
-
assert,
|
|
24
|
-
ensure0x,
|
|
25
|
-
isZeroishAddress,
|
|
26
|
-
} from '@hyperlane-xyz/utils';
|
|
18
|
+
import { ProtocolType, assert, ensure0x, fromWei } from '@hyperlane-xyz/utils';
|
|
27
19
|
|
|
28
20
|
import type { ExternalBridgeType } from '../config/types.js';
|
|
29
21
|
import type {
|
|
@@ -51,6 +43,11 @@ import {
|
|
|
51
43
|
} from '../utils/tokenUtils.js';
|
|
52
44
|
import { parseSolanaPrivateKey } from '../utils/solanaKeyParser.js';
|
|
53
45
|
import { toProtocolTransaction } from '../utils/transactionUtils.js';
|
|
46
|
+
import {
|
|
47
|
+
alignLocalToCanonical,
|
|
48
|
+
denormalizeToLocal,
|
|
49
|
+
normalizeToCanonical,
|
|
50
|
+
} from '../utils/balanceUtils.js';
|
|
54
51
|
|
|
55
52
|
/**
|
|
56
53
|
* Buffer percentage to add when bridging inventory.
|
|
@@ -72,6 +69,87 @@ const GAS_COST_MULTIPLIER = 20n;
|
|
|
72
69
|
*/
|
|
73
70
|
const MAX_GAS_PERCENT_THRESHOLD = 10n;
|
|
74
71
|
|
|
72
|
+
type BridgeCapacity = {
|
|
73
|
+
maxSourceInput: bigint;
|
|
74
|
+
maxTargetOutput: bigint;
|
|
75
|
+
};
|
|
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
|
+
|
|
93
|
+
const RECOVERABLE_MAX_TRANSFER_ERROR_MESSAGES = [
|
|
94
|
+
'balance may be insufficient',
|
|
95
|
+
'transfer amount exceeds balance',
|
|
96
|
+
'insufficient balance',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
function hasRecoverableMaxTransferErrorMessage(message: string): boolean {
|
|
100
|
+
const normalized = message.toLowerCase();
|
|
101
|
+
return (
|
|
102
|
+
normalized.includes('unpredictable_gas_limit') ||
|
|
103
|
+
RECOVERABLE_MAX_TRANSFER_ERROR_MESSAGES.some((pattern) =>
|
|
104
|
+
normalized.includes(pattern),
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isRecoverableMaxTransferProbeError(error: unknown): boolean {
|
|
110
|
+
const seen = new Set<unknown>();
|
|
111
|
+
const stack: unknown[] = [error];
|
|
112
|
+
|
|
113
|
+
while (stack.length > 0) {
|
|
114
|
+
const current = stack.pop();
|
|
115
|
+
if (current == null) continue;
|
|
116
|
+
|
|
117
|
+
if (typeof current === 'string') {
|
|
118
|
+
if (hasRecoverableMaxTransferErrorMessage(current)) return true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (typeof current !== 'object') continue;
|
|
123
|
+
if (seen.has(current)) continue;
|
|
124
|
+
seen.add(current);
|
|
125
|
+
|
|
126
|
+
const candidate = current as {
|
|
127
|
+
code?: unknown;
|
|
128
|
+
message?: unknown;
|
|
129
|
+
cause?: unknown;
|
|
130
|
+
error?: unknown;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if (
|
|
134
|
+
typeof candidate.code === 'string' &&
|
|
135
|
+
candidate.code.toUpperCase() === 'UNPREDICTABLE_GAS_LIMIT'
|
|
136
|
+
) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
typeof candidate.message === 'string' &&
|
|
142
|
+
hasRecoverableMaxTransferErrorMessage(candidate.message)
|
|
143
|
+
) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
stack.push(candidate.cause, candidate.error);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
75
153
|
/**
|
|
76
154
|
* Configuration for the InventoryRebalancer.
|
|
77
155
|
*/
|
|
@@ -288,6 +366,10 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
288
366
|
return total;
|
|
289
367
|
}
|
|
290
368
|
|
|
369
|
+
private formatLocalAmount(amount: bigint, token: Token): string {
|
|
370
|
+
return fromWei(amount.toString(), token.decimals);
|
|
371
|
+
}
|
|
372
|
+
|
|
291
373
|
/**
|
|
292
374
|
* Get the effective available inventory for a chain, accounting for
|
|
293
375
|
* inventory already consumed during this execution cycle.
|
|
@@ -533,12 +615,18 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
533
615
|
{
|
|
534
616
|
strategyRoute: `${origin} (surplus) → ${destination} (deficit)`,
|
|
535
617
|
executionRoute: `transferRemote FROM ${destination} TO ${origin}`,
|
|
536
|
-
|
|
618
|
+
canonicalAmount: amount.toString(),
|
|
537
619
|
intentId: intent.id,
|
|
538
620
|
},
|
|
539
621
|
'Executing inventory route',
|
|
540
622
|
);
|
|
541
623
|
|
|
624
|
+
const sourceToken = this.getTokenForChain(destination);
|
|
625
|
+
assert(sourceToken, `No token found for source chain: ${destination}`);
|
|
626
|
+
const requestedLocalAmount = denormalizeToLocal(amount, sourceToken);
|
|
627
|
+
const executionSender = this.getInventorySignerAddress(destination);
|
|
628
|
+
const executionRecipient = this.getInventorySignerAddress(origin);
|
|
629
|
+
|
|
542
630
|
// Check available inventory on the DESTINATION (deficit) chain
|
|
543
631
|
// We need inventory here because transferRemote is called FROM this chain
|
|
544
632
|
const availableInventory = this.getEffectiveAvailableInventory(destination);
|
|
@@ -547,9 +635,15 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
547
635
|
{
|
|
548
636
|
checkingChain: destination,
|
|
549
637
|
availableInventory: availableInventory.toString(),
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
638
|
+
availableInventoryFormatted: this.formatLocalAmount(
|
|
639
|
+
availableInventory,
|
|
640
|
+
sourceToken,
|
|
641
|
+
),
|
|
642
|
+
requiredAmount: requestedLocalAmount.toString(),
|
|
643
|
+
requiredAmountFormatted: this.formatLocalAmount(
|
|
644
|
+
requestedLocalAmount,
|
|
645
|
+
sourceToken,
|
|
646
|
+
),
|
|
553
647
|
},
|
|
554
648
|
'Checking effective inventory on destination (deficit) chain',
|
|
555
649
|
);
|
|
@@ -560,15 +654,72 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
560
654
|
destination, // FROM chain (where transferRemote is called)
|
|
561
655
|
origin, // TO chain (where Hyperlane message goes)
|
|
562
656
|
availableInventory,
|
|
563
|
-
|
|
657
|
+
requestedLocalAmount,
|
|
564
658
|
this.multiProvider,
|
|
565
659
|
this.warpCore.multiProvider,
|
|
566
660
|
this.getTokenForChain.bind(this),
|
|
567
|
-
|
|
661
|
+
executionSender,
|
|
568
662
|
isNativeTokenStandard,
|
|
569
663
|
this.logger,
|
|
570
664
|
);
|
|
571
|
-
const {
|
|
665
|
+
const { minViableTransfer } = costs;
|
|
666
|
+
let maxTransferable = costs.maxTransferable;
|
|
667
|
+
|
|
668
|
+
if (!isNativeTokenStandard(sourceToken.standard)) {
|
|
669
|
+
if (availableInventory === 0n) {
|
|
670
|
+
maxTransferable = 0n;
|
|
671
|
+
this.logger.debug(
|
|
672
|
+
{
|
|
673
|
+
fromChain: destination,
|
|
674
|
+
toChain: origin,
|
|
675
|
+
requestedAmount: requestedLocalAmount.toString(),
|
|
676
|
+
},
|
|
677
|
+
'Skipping fee-aware max transferable probe because destination inventory is zero',
|
|
678
|
+
);
|
|
679
|
+
} else {
|
|
680
|
+
try {
|
|
681
|
+
const feeAwareMaxTransfer = await this.warpCore.getMaxTransferAmount({
|
|
682
|
+
balance: sourceToken.amount(availableInventory),
|
|
683
|
+
destination: origin,
|
|
684
|
+
sender: executionSender,
|
|
685
|
+
recipient: executionRecipient,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
maxTransferable =
|
|
689
|
+
feeAwareMaxTransfer.amount < requestedLocalAmount
|
|
690
|
+
? feeAwareMaxTransfer.amount
|
|
691
|
+
: requestedLocalAmount;
|
|
692
|
+
|
|
693
|
+
this.logger.debug(
|
|
694
|
+
{
|
|
695
|
+
fromChain: destination,
|
|
696
|
+
toChain: origin,
|
|
697
|
+
availableInventory: availableInventory.toString(),
|
|
698
|
+
requestedAmount: requestedLocalAmount.toString(),
|
|
699
|
+
feeAwareMaxTransferable: maxTransferable.toString(),
|
|
700
|
+
},
|
|
701
|
+
'Calculated fee-aware max transferable amount for non-native route',
|
|
702
|
+
);
|
|
703
|
+
} catch (error) {
|
|
704
|
+
if (!isRecoverableMaxTransferProbeError(error)) {
|
|
705
|
+
throw error;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
maxTransferable = 0n;
|
|
709
|
+
this.logger.warn(
|
|
710
|
+
{
|
|
711
|
+
fromChain: destination,
|
|
712
|
+
toChain: origin,
|
|
713
|
+
availableInventory: availableInventory.toString(),
|
|
714
|
+
requestedAmount: requestedLocalAmount.toString(),
|
|
715
|
+
error: error instanceof Error ? error.message : String(error),
|
|
716
|
+
intentId: intent.id,
|
|
717
|
+
},
|
|
718
|
+
'Fee-aware max transferable probe failed due to insufficient balance, falling back to external bridge',
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
572
723
|
|
|
573
724
|
// Calculate total inventory across all chains
|
|
574
725
|
// Note: consumedInventory tracking is handled separately within this cycle
|
|
@@ -578,24 +729,36 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
578
729
|
{
|
|
579
730
|
fromChain: destination,
|
|
580
731
|
toChain: origin,
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
732
|
+
availableInventoryFormatted: this.formatLocalAmount(
|
|
733
|
+
availableInventory,
|
|
734
|
+
sourceToken,
|
|
735
|
+
),
|
|
736
|
+
requestedAmountFormatted: this.formatLocalAmount(
|
|
737
|
+
requestedLocalAmount,
|
|
738
|
+
sourceToken,
|
|
739
|
+
),
|
|
740
|
+
maxTransferableFormatted: this.formatLocalAmount(
|
|
741
|
+
maxTransferable,
|
|
742
|
+
sourceToken,
|
|
743
|
+
),
|
|
744
|
+
minViableTransferFormatted: this.formatLocalAmount(
|
|
745
|
+
minViableTransfer,
|
|
746
|
+
sourceToken,
|
|
747
|
+
),
|
|
748
|
+
canFullyFulfill: maxTransferable >= requestedLocalAmount,
|
|
587
749
|
canPartialFulfill: maxTransferable >= minViableTransfer,
|
|
750
|
+
totalInventory: totalInventory.toString(),
|
|
588
751
|
},
|
|
589
752
|
'Calculated max transferable amount with cost-based threshold',
|
|
590
753
|
);
|
|
591
754
|
|
|
592
755
|
// Early exit: If remaining amount is below minViableTransfer, complete the intent
|
|
593
756
|
// This prevents infinite loops when the remaining amount is too small to economically bridge
|
|
594
|
-
if (
|
|
757
|
+
if (requestedLocalAmount < minViableTransfer) {
|
|
595
758
|
this.logger.info(
|
|
596
759
|
{
|
|
597
760
|
intentId: intent.id,
|
|
598
|
-
amount:
|
|
761
|
+
amount: requestedLocalAmount.toString(),
|
|
599
762
|
minViableTransfer: minViableTransfer.toString(),
|
|
600
763
|
},
|
|
601
764
|
'Remaining amount below minViableTransfer, completing intent with acceptable loss',
|
|
@@ -616,227 +779,291 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
616
779
|
...route,
|
|
617
780
|
origin: destination, // transferRemote called FROM here
|
|
618
781
|
destination: origin, // Hyperlane message goes TO here
|
|
782
|
+
amount: requestedLocalAmount,
|
|
619
783
|
};
|
|
620
784
|
|
|
621
|
-
if (maxTransferable >=
|
|
785
|
+
if (maxTransferable >= requestedLocalAmount) {
|
|
622
786
|
// Sufficient inventory on destination - execute transferRemote directly
|
|
787
|
+
const fulfilledCanonicalAmount = normalizeToCanonical(
|
|
788
|
+
requestedLocalAmount,
|
|
789
|
+
sourceToken,
|
|
790
|
+
);
|
|
623
791
|
const result = await this.executeTransferRemote(
|
|
624
792
|
swappedRoute,
|
|
625
793
|
intent,
|
|
626
|
-
|
|
794
|
+
fulfilledCanonicalAmount,
|
|
627
795
|
);
|
|
628
796
|
// Return original strategy route in result (not the swapped execution route)
|
|
629
797
|
return { ...result, route };
|
|
630
798
|
} else if (maxTransferable > 0n && maxTransferable >= minViableTransfer) {
|
|
631
799
|
// Partial transfer: Transfer available inventory when economically viable
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
};
|
|
636
|
-
const result = await this.executeTransferRemote(
|
|
637
|
-
partialSwappedRoute,
|
|
638
|
-
intent,
|
|
639
|
-
costs.gasQuote!,
|
|
640
|
-
);
|
|
641
|
-
|
|
642
|
-
this.logger.info(
|
|
643
|
-
{
|
|
644
|
-
intentId: intent.id,
|
|
645
|
-
partialAmount: maxTransferable.toString(),
|
|
646
|
-
requestedAmount: amount.toString(),
|
|
647
|
-
remainingAmount: (amount - maxTransferable).toString(),
|
|
648
|
-
},
|
|
649
|
-
'Executed partial inventory deposit, remaining will be handled in future cycles',
|
|
650
|
-
);
|
|
651
|
-
|
|
652
|
-
// Return original strategy route in result (not the swapped execution route)
|
|
653
|
-
return { ...result, route };
|
|
654
|
-
} else {
|
|
655
|
-
// Inventory below cost-based threshold - trigger ExternalBridge movement TO destination chain
|
|
656
|
-
this.logger.info(
|
|
657
|
-
{
|
|
658
|
-
targetChain: destination,
|
|
659
|
-
maxTransferable: maxTransferable.toString(),
|
|
660
|
-
minViableTransfer: minViableTransfer.toString(),
|
|
661
|
-
costMultiplier: MIN_VIABLE_COST_MULTIPLIER.toString(),
|
|
662
|
-
intentId: intent.id,
|
|
663
|
-
},
|
|
664
|
-
'Inventory below cost-based threshold on destination, triggering LiFi movement',
|
|
800
|
+
const alignedExecution = alignLocalToCanonical(
|
|
801
|
+
maxTransferable,
|
|
802
|
+
sourceToken,
|
|
665
803
|
);
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
const allSources = this.selectAllSourceChains(destination);
|
|
669
|
-
|
|
670
|
-
if (allSources.length === 0) {
|
|
671
|
-
this.logger.warn(
|
|
804
|
+
if (alignedExecution.messageAmount === 0n) {
|
|
805
|
+
this.logger.info(
|
|
672
806
|
{
|
|
673
|
-
origin,
|
|
674
|
-
destination,
|
|
675
|
-
amount: amount.toString(),
|
|
676
807
|
intentId: intent.id,
|
|
808
|
+
maxTransferable: maxTransferable.toString(),
|
|
677
809
|
},
|
|
678
|
-
'
|
|
810
|
+
'Skipping partial transferRemote because available local amount cannot produce canonical progress',
|
|
679
811
|
);
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
error: 'No inventory available on any monitored chain',
|
|
812
|
+
} else {
|
|
813
|
+
const partialSwappedRoute: InventoryRoute = {
|
|
814
|
+
...swappedRoute,
|
|
815
|
+
amount: alignedExecution.localAmount,
|
|
685
816
|
};
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
const viableSources: Array<{ chain: ChainName; maxViable: bigint }> = [];
|
|
691
|
-
|
|
692
|
-
for (const source of allSources) {
|
|
693
|
-
const maxViable = await this.calculateMaxViableBridgeAmount(
|
|
694
|
-
source.chain,
|
|
695
|
-
destination,
|
|
696
|
-
source.availableAmount,
|
|
697
|
-
route.externalBridge,
|
|
817
|
+
const result = await this.executeTransferRemote(
|
|
818
|
+
partialSwappedRoute,
|
|
819
|
+
intent,
|
|
820
|
+
alignedExecution.messageAmount,
|
|
698
821
|
);
|
|
699
822
|
|
|
700
|
-
|
|
701
|
-
viableSources.push({ chain: source.chain, maxViable });
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
// Sort by max viable descending (bridge from largest sources first)
|
|
706
|
-
viableSources.sort((a, b) => (a.maxViable > b.maxViable ? -1 : 1));
|
|
707
|
-
|
|
708
|
-
if (viableSources.length === 0) {
|
|
709
|
-
this.logger.warn(
|
|
823
|
+
this.logger.info(
|
|
710
824
|
{
|
|
711
|
-
targetChain: destination,
|
|
712
|
-
sourcesChecked: allSources.length,
|
|
713
825
|
intentId: intent.id,
|
|
826
|
+
partialAmount: alignedExecution.localAmount.toString(),
|
|
827
|
+
partialAmountCanonical: alignedExecution.messageAmount.toString(),
|
|
828
|
+
requestedAmount: requestedLocalAmount.toString(),
|
|
829
|
+
requestedAmountCanonical: amount.toString(),
|
|
830
|
+
remainingAmountCanonical: (amount > alignedExecution.messageAmount
|
|
831
|
+
? amount - alignedExecution.messageAmount
|
|
832
|
+
: 0n
|
|
833
|
+
).toString(),
|
|
714
834
|
},
|
|
715
|
-
'
|
|
835
|
+
'Executed partial inventory deposit, remaining will be handled in future cycles',
|
|
716
836
|
);
|
|
717
837
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
success: false,
|
|
721
|
-
error: 'No viable bridge sources available',
|
|
722
|
-
};
|
|
838
|
+
// Return original strategy route in result (not the swapped execution route)
|
|
839
|
+
return { ...result, route };
|
|
723
840
|
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Inventory below cost-based threshold - trigger ExternalBridge movement TO destination chain
|
|
844
|
+
this.logger.info(
|
|
845
|
+
{
|
|
846
|
+
targetChain: destination,
|
|
847
|
+
maxTransferable: maxTransferable.toString(),
|
|
848
|
+
minViableTransfer: minViableTransfer.toString(),
|
|
849
|
+
costMultiplier: MIN_VIABLE_COST_MULTIPLIER.toString(),
|
|
850
|
+
intentId: intent.id,
|
|
851
|
+
},
|
|
852
|
+
'Inventory below cost-based threshold on destination, triggering LiFi movement',
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
// Get all available source chains with raw inventory
|
|
856
|
+
const allSources = this.selectAllSourceChains(destination);
|
|
857
|
+
|
|
858
|
+
if (allSources.length === 0) {
|
|
859
|
+
this.logger.warn(
|
|
860
|
+
{
|
|
861
|
+
origin,
|
|
862
|
+
destination,
|
|
863
|
+
amount: requestedLocalAmount.toString(),
|
|
864
|
+
intentId: intent.id,
|
|
865
|
+
},
|
|
866
|
+
'No inventory available on any monitored chain',
|
|
867
|
+
);
|
|
724
868
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
869
|
+
return {
|
|
870
|
+
route,
|
|
871
|
+
success: false,
|
|
872
|
+
error: 'No inventory available on any monitored chain',
|
|
873
|
+
};
|
|
874
|
+
}
|
|
730
875
|
|
|
731
|
-
|
|
732
|
-
|
|
876
|
+
// Calculate source capacities in destination-local units.
|
|
877
|
+
const viableSources: Array<{
|
|
878
|
+
chain: ChainName;
|
|
879
|
+
maxSourceInput: bigint;
|
|
880
|
+
maxTargetOutput: bigint;
|
|
881
|
+
}> = [];
|
|
733
882
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
883
|
+
for (const source of allSources) {
|
|
884
|
+
const capacity = await this.calculateBridgeCapacity(
|
|
885
|
+
source.chain,
|
|
886
|
+
destination,
|
|
887
|
+
source.availableAmount,
|
|
888
|
+
route.externalBridge,
|
|
889
|
+
);
|
|
737
890
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
amount: amountFromSource,
|
|
741
|
-
});
|
|
742
|
-
totalPlanned += amountFromSource;
|
|
891
|
+
if (capacity.maxTargetOutput > 0n) {
|
|
892
|
+
viableSources.push({ chain: source.chain, ...capacity });
|
|
743
893
|
}
|
|
894
|
+
}
|
|
744
895
|
|
|
745
|
-
|
|
896
|
+
// Sort by destination output descending.
|
|
897
|
+
viableSources.sort((a, b) =>
|
|
898
|
+
a.maxTargetOutput > b.maxTargetOutput ? -1 : 1,
|
|
899
|
+
);
|
|
900
|
+
|
|
901
|
+
if (viableSources.length === 0) {
|
|
902
|
+
this.logger.warn(
|
|
746
903
|
{
|
|
747
904
|
targetChain: destination,
|
|
748
|
-
|
|
749
|
-
chain: s.chain,
|
|
750
|
-
maxViable: s.maxViable.toString(),
|
|
751
|
-
maxViableEth: (Number(s.maxViable) / 1e18).toFixed(6),
|
|
752
|
-
})),
|
|
753
|
-
bridgePlans: bridgePlans.map((p) => ({
|
|
754
|
-
chain: p.chain,
|
|
755
|
-
amount: p.amount.toString(),
|
|
756
|
-
amountEth: (Number(p.amount) / 1e18).toFixed(6),
|
|
757
|
-
})),
|
|
758
|
-
totalPlanned: totalPlanned.toString(),
|
|
759
|
-
targetWithBuffer: targetWithBuffer.toString(),
|
|
905
|
+
sourcesChecked: allSources.length,
|
|
760
906
|
intentId: intent.id,
|
|
761
907
|
},
|
|
762
|
-
'
|
|
908
|
+
'No viable bridge sources - all chains have insufficient inventory or high gas costs',
|
|
763
909
|
);
|
|
764
910
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
911
|
+
return {
|
|
912
|
+
route,
|
|
913
|
+
success: false,
|
|
914
|
+
error: 'No viable bridge sources available',
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Create bridge plans using destination-local output amounts.
|
|
919
|
+
const shortfall =
|
|
920
|
+
requestedLocalAmount > availableInventory
|
|
921
|
+
? requestedLocalAmount - availableInventory
|
|
922
|
+
: 0n;
|
|
923
|
+
const targetWithBuffer =
|
|
924
|
+
((shortfall + costs.totalCost) * (100n + BRIDGE_BUFFER_PERCENT)) / 100n;
|
|
925
|
+
const bridgePlans: Array<{
|
|
926
|
+
chain: ChainName;
|
|
927
|
+
maxSourceInput: bigint;
|
|
928
|
+
targetOutput: bigint;
|
|
929
|
+
quoteMode: BridgeQuoteMode;
|
|
930
|
+
}> = [];
|
|
931
|
+
let totalPlanned = 0n;
|
|
932
|
+
|
|
933
|
+
for (const source of viableSources) {
|
|
934
|
+
if (totalPlanned >= targetWithBuffer) break;
|
|
935
|
+
|
|
936
|
+
const remaining = targetWithBuffer - totalPlanned;
|
|
937
|
+
const targetOutput =
|
|
938
|
+
source.maxTargetOutput >= remaining
|
|
939
|
+
? remaining
|
|
940
|
+
: source.maxTargetOutput;
|
|
941
|
+
const quoteMode: BridgeQuoteMode =
|
|
942
|
+
source.maxTargetOutput > remaining ? 'reverse' : 'forward';
|
|
943
|
+
|
|
944
|
+
bridgePlans.push({
|
|
945
|
+
chain: source.chain,
|
|
946
|
+
maxSourceInput: source.maxSourceInput,
|
|
947
|
+
targetOutput,
|
|
948
|
+
quoteMode,
|
|
949
|
+
});
|
|
950
|
+
totalPlanned += targetOutput;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
this.logger.info(
|
|
954
|
+
{
|
|
955
|
+
targetChain: destination,
|
|
956
|
+
viableSources: viableSources.map((s) => ({
|
|
957
|
+
chain: s.chain,
|
|
958
|
+
maxSourceInput: s.maxSourceInput.toString(),
|
|
959
|
+
maxTargetOutput: s.maxTargetOutput.toString(),
|
|
960
|
+
})),
|
|
961
|
+
bridgePlans: bridgePlans.map((p) => ({
|
|
962
|
+
chain: p.chain,
|
|
963
|
+
maxSourceInput: p.maxSourceInput.toString(),
|
|
964
|
+
targetOutput: p.targetOutput.toString(),
|
|
965
|
+
quoteMode: p.quoteMode,
|
|
966
|
+
})),
|
|
967
|
+
totalPlanned: totalPlanned.toString(),
|
|
968
|
+
shortfall: shortfall.toString(),
|
|
969
|
+
targetWithBuffer: targetWithBuffer.toString(),
|
|
970
|
+
intentId: intent.id,
|
|
971
|
+
},
|
|
972
|
+
'Created bridge plans using gas-adjusted viable amounts',
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
// Execute all bridges in parallel
|
|
976
|
+
const bridgeResults = await Promise.allSettled(
|
|
977
|
+
bridgePlans.map((plan) =>
|
|
978
|
+
this.executeInventoryMovement(
|
|
979
|
+
plan.chain,
|
|
980
|
+
destination,
|
|
981
|
+
plan.targetOutput,
|
|
982
|
+
plan.maxSourceInput,
|
|
983
|
+
plan.quoteMode,
|
|
984
|
+
intent,
|
|
985
|
+
route.externalBridge,
|
|
775
986
|
),
|
|
776
|
-
)
|
|
987
|
+
),
|
|
988
|
+
);
|
|
777
989
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
990
|
+
// Process results
|
|
991
|
+
let successCount = 0;
|
|
992
|
+
let totalQuotedOutputMin = 0n;
|
|
993
|
+
const failedErrors: string[] = [];
|
|
782
994
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
995
|
+
for (let i = 0; i < bridgeResults.length; i++) {
|
|
996
|
+
const result = bridgeResults[i];
|
|
997
|
+
const plan = bridgePlans[i];
|
|
786
998
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
999
|
+
if (result.status === 'fulfilled' && result.value.success) {
|
|
1000
|
+
successCount++;
|
|
1001
|
+
totalQuotedOutputMin += result.value.quotedOutputMin;
|
|
1002
|
+
this.logger.info(
|
|
1003
|
+
{
|
|
1004
|
+
sourceChain: plan.chain,
|
|
1005
|
+
plannedTargetOutput: plan.targetOutput.toString(),
|
|
1006
|
+
quotedOutput: result.value.quotedOutput.toString(),
|
|
1007
|
+
quotedOutputMin: result.value.quotedOutputMin.toString(),
|
|
1008
|
+
quoteModeUsed: result.value.quoteModeUsed,
|
|
1009
|
+
txHash: result.value.txHash,
|
|
1010
|
+
},
|
|
1011
|
+
'Inventory movement succeeded',
|
|
1012
|
+
);
|
|
1013
|
+
} else {
|
|
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
|
+
}
|
|
805
1026
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
},
|
|
812
|
-
'Inventory movement failed',
|
|
813
|
-
);
|
|
1027
|
+
} else if (!result.value.success) {
|
|
1028
|
+
error = result.value.error;
|
|
1029
|
+
}
|
|
1030
|
+
if (error) {
|
|
1031
|
+
failedErrors.push(`${plan.chain}: ${error}`);
|
|
814
1032
|
}
|
|
1033
|
+
this.logger.warn(
|
|
1034
|
+
{
|
|
1035
|
+
sourceChain: plan.chain,
|
|
1036
|
+
plannedTargetOutput: plan.targetOutput.toString(),
|
|
1037
|
+
error,
|
|
1038
|
+
},
|
|
1039
|
+
'Inventory movement failed',
|
|
1040
|
+
);
|
|
815
1041
|
}
|
|
1042
|
+
}
|
|
816
1043
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1044
|
+
if (successCount === 0) {
|
|
1045
|
+
const errorDetails =
|
|
1046
|
+
failedErrors.length > 0 ? ` (${failedErrors.join('; ')})` : '';
|
|
1047
|
+
return {
|
|
1048
|
+
route,
|
|
1049
|
+
success: false,
|
|
1050
|
+
error: `All inventory movements failed${errorDetails}`,
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
826
1053
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1054
|
+
this.logger.info(
|
|
1055
|
+
{
|
|
1056
|
+
targetChain: destination,
|
|
1057
|
+
successCount,
|
|
1058
|
+
totalQuotedOutputMin: totalQuotedOutputMin.toString(),
|
|
1059
|
+
targetAmount: requestedLocalAmount.toString(),
|
|
1060
|
+
targetAmountCanonical: amount.toString(),
|
|
1061
|
+
intentId: intent.id,
|
|
1062
|
+
},
|
|
1063
|
+
'Parallel inventory movements completed, transferRemote will execute after bridges complete',
|
|
1064
|
+
);
|
|
837
1065
|
|
|
838
|
-
|
|
839
|
-
}
|
|
1066
|
+
return { route, success: true };
|
|
840
1067
|
}
|
|
841
1068
|
|
|
842
1069
|
/**
|
|
@@ -852,12 +1079,11 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
852
1079
|
*
|
|
853
1080
|
* @param route - The transfer route (swapped direction)
|
|
854
1081
|
* @param intent - The rebalance intent being executed
|
|
855
|
-
* @param gasQuote - Pre-calculated gas quote from calculateTransferCosts
|
|
856
1082
|
*/
|
|
857
1083
|
private async executeTransferRemote(
|
|
858
1084
|
route: InventoryRoute,
|
|
859
1085
|
intent: RebalanceIntent,
|
|
860
|
-
|
|
1086
|
+
fulfilledCanonicalAmount: bigint,
|
|
861
1087
|
): Promise<InventoryExecutionResult> {
|
|
862
1088
|
const { origin, destination, amount } = route;
|
|
863
1089
|
|
|
@@ -869,52 +1095,16 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
869
1095
|
const destinationDomain = this.multiProvider.getDomainId(destination);
|
|
870
1096
|
|
|
871
1097
|
this.logger.debug(
|
|
872
|
-
{
|
|
873
|
-
|
|
874
|
-
destination,
|
|
875
|
-
amount: amount.toString(),
|
|
876
|
-
gasQuote: {
|
|
877
|
-
igpQuote: gasQuote.igpQuote.amount.toString(),
|
|
878
|
-
tokenFeeQuote: gasQuote.tokenFeeQuote?.amount?.toString() ?? 'none',
|
|
879
|
-
},
|
|
880
|
-
},
|
|
881
|
-
'Using pre-calculated gas quote for transferRemote',
|
|
1098
|
+
{ origin, destination, amount: amount.toString() },
|
|
1099
|
+
'Building transferRemote transactions for exact execution amount',
|
|
882
1100
|
);
|
|
883
1101
|
|
|
884
|
-
// Convert pre-calculated gas quote to TokenAmount for WarpCore
|
|
885
|
-
const originChainMetadata = this.multiProvider.getChainMetadata(origin);
|
|
886
|
-
const igpAddressOrDenom = gasQuote.igpQuote.addressOrDenom;
|
|
887
|
-
const igpToken =
|
|
888
|
-
!igpAddressOrDenom || isZeroishAddress(igpAddressOrDenom)
|
|
889
|
-
? Token.FromChainMetadataNativeToken(originChainMetadata)
|
|
890
|
-
: this.warpCore.findToken(origin, igpAddressOrDenom);
|
|
891
|
-
assert(igpToken, `IGP fee token ${igpAddressOrDenom} is unknown`);
|
|
892
|
-
const interchainFee = new TokenAmount<IToken>(
|
|
893
|
-
gasQuote.igpQuote.amount,
|
|
894
|
-
igpToken,
|
|
895
|
-
);
|
|
896
|
-
|
|
897
|
-
let tokenFeeQuote: TokenAmount<IToken> | undefined;
|
|
898
|
-
if (gasQuote.tokenFeeQuote?.amount) {
|
|
899
|
-
const feeAddress = gasQuote.tokenFeeQuote.addressOrDenom;
|
|
900
|
-
const feeToken =
|
|
901
|
-
!feeAddress || isZeroishAddress(feeAddress)
|
|
902
|
-
? Token.FromChainMetadataNativeToken(originChainMetadata)
|
|
903
|
-
: originToken;
|
|
904
|
-
tokenFeeQuote = new TokenAmount<IToken>(
|
|
905
|
-
gasQuote.tokenFeeQuote.amount,
|
|
906
|
-
feeToken,
|
|
907
|
-
);
|
|
908
|
-
}
|
|
909
|
-
|
|
910
1102
|
const originTokenAmount = originToken.amount(amount);
|
|
911
1103
|
const transferTxs = await this.warpCore.getTransferRemoteTxs({
|
|
912
1104
|
originTokenAmount,
|
|
913
1105
|
destination,
|
|
914
1106
|
sender: this.getInventorySignerAddress(origin),
|
|
915
1107
|
recipient: this.getInventorySignerAddress(destination),
|
|
916
|
-
interchainFee,
|
|
917
|
-
tokenFeeQuote,
|
|
918
1108
|
});
|
|
919
1109
|
assert(
|
|
920
1110
|
transferTxs.length > 0,
|
|
@@ -974,7 +1164,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
974
1164
|
intentId: intent.id,
|
|
975
1165
|
origin: this.multiProvider.getDomainId(origin),
|
|
976
1166
|
destination: destinationDomain,
|
|
977
|
-
amount,
|
|
1167
|
+
amount: fulfilledCanonicalAmount,
|
|
978
1168
|
type: 'inventory_deposit',
|
|
979
1169
|
txHash: transferTxHash,
|
|
980
1170
|
messageId,
|
|
@@ -1129,40 +1319,30 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
1129
1319
|
}
|
|
1130
1320
|
|
|
1131
1321
|
/**
|
|
1132
|
-
* Calculate the
|
|
1133
|
-
* Uses LiFi
|
|
1134
|
-
*
|
|
1322
|
+
* Calculate the bridge capacity from a source chain in destination-local units.
|
|
1323
|
+
* Uses LiFi quotes to conservatively estimate the destination output available
|
|
1324
|
+
* from the source chain's current local inventory.
|
|
1135
1325
|
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
* - Applies conservative 20x buffer (LiFi underestimates by ~14x historically)
|
|
1139
|
-
* - Returns 0 if gas > 10% of inventory (not worth bridging)
|
|
1140
|
-
* - Returns inventory - estimatedGas if viable
|
|
1141
|
-
*
|
|
1142
|
-
* @param sourceChain - Chain to bridge from
|
|
1143
|
-
* @param targetChain - Chain to bridge to
|
|
1144
|
-
* @param rawInventory - Raw available inventory on source chain
|
|
1145
|
-
* @param externalBridgeType - External bridge type to use
|
|
1146
|
-
* @returns Maximum viable bridge amount (0 if not viable)
|
|
1326
|
+
* For native-token sources, gas is reserved from the source inventory and the
|
|
1327
|
+
* output capacity is re-quoted from the remaining source input.
|
|
1147
1328
|
*/
|
|
1148
|
-
private async
|
|
1329
|
+
private async calculateBridgeCapacity(
|
|
1149
1330
|
sourceChain: ChainName,
|
|
1150
1331
|
targetChain: ChainName,
|
|
1151
1332
|
rawInventory: bigint,
|
|
1152
1333
|
externalBridgeType: ExternalBridgeType,
|
|
1153
|
-
): Promise<
|
|
1334
|
+
): Promise<BridgeCapacity> {
|
|
1154
1335
|
const sourceToken = this.getTokenForChain(sourceChain);
|
|
1155
1336
|
const targetToken = this.getTokenForChain(targetChain);
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
// Only applies to native tokens (need gas from same balance)
|
|
1160
|
-
if (!isNativeTokenStandard(sourceToken.standard)) {
|
|
1161
|
-
return rawInventory; // ERC20s don't compete with gas
|
|
1162
|
-
}
|
|
1337
|
+
assert(sourceToken, `No token found for source chain: ${sourceChain}`);
|
|
1338
|
+
assert(targetToken, `No token found for target chain: ${targetChain}`);
|
|
1163
1339
|
|
|
1164
1340
|
// Convert HypNative token addresses to the external bridge's native token representation
|
|
1165
|
-
const fromTokenAddress =
|
|
1341
|
+
const fromTokenAddress = getExternalBridgeTokenAddress(
|
|
1342
|
+
sourceToken,
|
|
1343
|
+
externalBridgeType,
|
|
1344
|
+
this.getNativeTokenAddress.bind(this),
|
|
1345
|
+
);
|
|
1166
1346
|
const toTokenAddress = getExternalBridgeTokenAddress(
|
|
1167
1347
|
targetToken,
|
|
1168
1348
|
externalBridgeType,
|
|
@@ -1174,7 +1354,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
1174
1354
|
|
|
1175
1355
|
try {
|
|
1176
1356
|
const externalBridge = this.getExternalBridge(externalBridgeType);
|
|
1177
|
-
const
|
|
1357
|
+
const initialQuote = await externalBridge.quote({
|
|
1178
1358
|
fromChain: sourceChainId,
|
|
1179
1359
|
toChain: targetChainId,
|
|
1180
1360
|
fromToken: fromTokenAddress,
|
|
@@ -1184,48 +1364,58 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
1184
1364
|
toAddress: this.getInventorySignerAddress(targetChain),
|
|
1185
1365
|
});
|
|
1186
1366
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1367
|
+
let maxSourceInput = rawInventory;
|
|
1368
|
+
let outputQuote = initialQuote;
|
|
1189
1369
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
return 0n;
|
|
1208
|
-
}
|
|
1370
|
+
if (isNativeTokenStandard(sourceToken.standard)) {
|
|
1371
|
+
const estimatedGas = initialQuote.gasCosts * GAS_COST_MULTIPLIER;
|
|
1372
|
+
const maxGasThreshold = rawInventory / MAX_GAS_PERCENT_THRESHOLD;
|
|
1373
|
+
if (estimatedGas > maxGasThreshold) {
|
|
1374
|
+
this.logger.info(
|
|
1375
|
+
{
|
|
1376
|
+
sourceChain,
|
|
1377
|
+
targetChain,
|
|
1378
|
+
rawInventory: rawInventory.toString(),
|
|
1379
|
+
quotedGas: initialQuote.gasCosts.toString(),
|
|
1380
|
+
estimatedGas: estimatedGas.toString(),
|
|
1381
|
+
maxGasThreshold: maxGasThreshold.toString(),
|
|
1382
|
+
},
|
|
1383
|
+
'Bridge not viable - gas cost exceeds 10% of inventory',
|
|
1384
|
+
);
|
|
1385
|
+
return { maxSourceInput: 0n, maxTargetOutput: 0n };
|
|
1386
|
+
}
|
|
1209
1387
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1388
|
+
maxSourceInput = rawInventory - estimatedGas;
|
|
1389
|
+
if (maxSourceInput <= 0n) {
|
|
1390
|
+
return { maxSourceInput: 0n, maxTargetOutput: 0n };
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
outputQuote = await externalBridge.quote({
|
|
1394
|
+
fromChain: sourceChainId,
|
|
1395
|
+
toChain: targetChainId,
|
|
1396
|
+
fromToken: fromTokenAddress,
|
|
1397
|
+
toToken: toTokenAddress,
|
|
1398
|
+
fromAmount: maxSourceInput,
|
|
1399
|
+
fromAddress: this.getInventorySignerAddress(sourceChain),
|
|
1400
|
+
toAddress: this.getInventorySignerAddress(targetChain),
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1212
1403
|
|
|
1213
1404
|
this.logger.info(
|
|
1214
1405
|
{
|
|
1215
1406
|
sourceChain,
|
|
1216
1407
|
targetChain,
|
|
1217
1408
|
rawInventory: rawInventory.toString(),
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
estimatedGas: estimatedGas.toString(),
|
|
1221
|
-
estimatedGasEth: (Number(estimatedGas) / 1e18).toFixed(6),
|
|
1222
|
-
maxViable: maxViable.toString(),
|
|
1223
|
-
maxViableEth: (Number(maxViable) / 1e18).toFixed(6),
|
|
1409
|
+
maxSourceInput: maxSourceInput.toString(),
|
|
1410
|
+
maxTargetOutput: outputQuote.toAmountMin.toString(),
|
|
1224
1411
|
},
|
|
1225
|
-
'Calculated
|
|
1412
|
+
'Calculated bridge capacity',
|
|
1226
1413
|
);
|
|
1227
1414
|
|
|
1228
|
-
return
|
|
1415
|
+
return {
|
|
1416
|
+
maxSourceInput,
|
|
1417
|
+
maxTargetOutput: outputQuote.toAmountMin,
|
|
1418
|
+
};
|
|
1229
1419
|
} catch (error) {
|
|
1230
1420
|
this.logger.warn(
|
|
1231
1421
|
{
|
|
@@ -1233,21 +1423,24 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
1233
1423
|
targetChain,
|
|
1234
1424
|
error: (error as Error).message,
|
|
1235
1425
|
},
|
|
1236
|
-
'Failed to calculate
|
|
1426
|
+
'Failed to calculate bridge capacity, skipping chain',
|
|
1237
1427
|
);
|
|
1238
|
-
return 0n;
|
|
1428
|
+
return { maxSourceInput: 0n, maxTargetOutput: 0n };
|
|
1239
1429
|
}
|
|
1240
1430
|
}
|
|
1241
1431
|
|
|
1242
1432
|
/**
|
|
1243
1433
|
* Execute inventory movement from source chain to target chain via LiFi bridge.
|
|
1244
1434
|
*
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
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
|
|
1247
1438
|
*
|
|
1248
1439
|
* @param sourceChain - Chain to move inventory from
|
|
1249
1440
|
* @param targetChain - Chain to move inventory to (origin chain for rebalancing)
|
|
1250
|
-
* @param
|
|
1441
|
+
* @param targetOutputAmount - Destination-local amount to receive
|
|
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
|
|
1251
1444
|
* @param intent - Rebalance intent for tracking
|
|
1252
1445
|
* @param externalBridgeType - External bridge type to use
|
|
1253
1446
|
* @returns Result with success status and optional txHash/error
|
|
@@ -1255,10 +1448,12 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
1255
1448
|
private async executeInventoryMovement(
|
|
1256
1449
|
sourceChain: ChainName,
|
|
1257
1450
|
targetChain: ChainName,
|
|
1258
|
-
|
|
1451
|
+
targetOutputAmount: bigint,
|
|
1452
|
+
maxSourceInput: bigint,
|
|
1453
|
+
quoteMode: BridgeQuoteMode,
|
|
1259
1454
|
intent: RebalanceIntent,
|
|
1260
1455
|
externalBridgeType: ExternalBridgeType,
|
|
1261
|
-
): Promise<
|
|
1456
|
+
): Promise<InventoryMovementExecutionResult> {
|
|
1262
1457
|
const sourceToken = this.getTokenForChain(sourceChain);
|
|
1263
1458
|
if (!sourceToken) {
|
|
1264
1459
|
return {
|
|
@@ -1304,57 +1499,53 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
1304
1499
|
'Resolved token addresses for LiFi bridge',
|
|
1305
1500
|
);
|
|
1306
1501
|
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1502
|
+
try {
|
|
1503
|
+
const externalBridge = this.getExternalBridge(externalBridgeType);
|
|
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
|
+
});
|
|
1323
1518
|
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
const effectiveAmount =
|
|
1327
|
-
amount < minViableTransfer ? minViableTransfer : amount;
|
|
1519
|
+
let quoteModeUsed = quoteMode;
|
|
1520
|
+
let quote = await quoteWithMode(quoteModeUsed);
|
|
1328
1521
|
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
);
|
|
1343
|
-
}
|
|
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
|
+
);
|
|
1344
1535
|
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
fromToken: fromTokenAddress,
|
|
1351
|
-
toToken: toTokenAddress,
|
|
1352
|
-
fromAmount: effectiveAmount,
|
|
1353
|
-
fromAddress: this.getInventorySignerAddress(sourceChain),
|
|
1354
|
-
toAddress: this.getInventorySignerAddress(targetChain),
|
|
1355
|
-
});
|
|
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
|
+
}
|
|
1356
1541
|
|
|
1357
1542
|
const inputRequired = quote.fromAmount;
|
|
1543
|
+
if (inputRequired > maxSourceInput) {
|
|
1544
|
+
return {
|
|
1545
|
+
success: false,
|
|
1546
|
+
error: `Bridge input ${inputRequired} exceeded planned source capacity ${maxSourceInput}`,
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1358
1549
|
|
|
1359
1550
|
this.logger.info(
|
|
1360
1551
|
{
|
|
@@ -1362,19 +1553,35 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
1362
1553
|
targetChain,
|
|
1363
1554
|
sourceChainId,
|
|
1364
1555
|
targetChainId,
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1556
|
+
requestedTargetOutput: targetOutputAmount.toString(),
|
|
1557
|
+
requestedTargetOutputFormatted: this.formatLocalAmount(
|
|
1558
|
+
targetOutputAmount,
|
|
1559
|
+
targetToken,
|
|
1560
|
+
),
|
|
1561
|
+
quoteModePlanned: quoteMode,
|
|
1562
|
+
quoteModeUsed,
|
|
1563
|
+
retriedAsForward:
|
|
1564
|
+
quoteMode === 'reverse' && quoteModeUsed === 'forward',
|
|
1369
1565
|
inputRequired: inputRequired.toString(),
|
|
1370
|
-
|
|
1371
|
-
|
|
1566
|
+
inputRequiredFormatted: this.formatLocalAmount(
|
|
1567
|
+
inputRequired,
|
|
1568
|
+
sourceToken,
|
|
1569
|
+
),
|
|
1570
|
+
quotedOutput: quote.toAmount.toString(),
|
|
1571
|
+
quotedOutputMin: quote.toAmountMin.toString(),
|
|
1572
|
+
quotedOutputFormatted: this.formatLocalAmount(
|
|
1573
|
+
quote.toAmount,
|
|
1574
|
+
targetToken,
|
|
1575
|
+
),
|
|
1576
|
+
quotedOutputMinFormatted: this.formatLocalAmount(
|
|
1577
|
+
quote.toAmountMin,
|
|
1578
|
+
targetToken,
|
|
1579
|
+
),
|
|
1372
1580
|
gasCosts: quote.gasCosts.toString(),
|
|
1373
1581
|
feeCosts: quote.feeCosts.toString(),
|
|
1374
1582
|
intentId: intent.id,
|
|
1375
|
-
adjustedForMinViable: effectiveAmount > amount,
|
|
1376
1583
|
},
|
|
1377
|
-
'Executing inventory movement via
|
|
1584
|
+
'Executing inventory movement via bridge quote',
|
|
1378
1585
|
);
|
|
1379
1586
|
|
|
1380
1587
|
this.logger.debug(
|
|
@@ -1417,6 +1624,8 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
1417
1624
|
'Inventory movement transaction executed',
|
|
1418
1625
|
);
|
|
1419
1626
|
|
|
1627
|
+
// Keep bridge consumption in source-local units; intent fulfillment only
|
|
1628
|
+
// advances from canonical inventory_deposit amounts after transferRemote.
|
|
1420
1629
|
await this.actionTracker.createRebalanceAction({
|
|
1421
1630
|
intentId: intent.id,
|
|
1422
1631
|
origin: this.multiProvider.getDomainId(sourceChain),
|
|
@@ -1440,22 +1649,31 @@ export class InventoryRebalancer implements IInventoryRebalancer {
|
|
|
1440
1649
|
'Updated consumed inventory after LiFi bridge',
|
|
1441
1650
|
);
|
|
1442
1651
|
|
|
1443
|
-
return {
|
|
1652
|
+
return {
|
|
1653
|
+
success: true,
|
|
1654
|
+
txHash: result.txHash,
|
|
1655
|
+
inputRequired,
|
|
1656
|
+
quotedOutput: quote.toAmount,
|
|
1657
|
+
quotedOutputMin: quote.toAmountMin,
|
|
1658
|
+
quoteModeUsed,
|
|
1659
|
+
};
|
|
1444
1660
|
} catch (error) {
|
|
1661
|
+
const errorMessage =
|
|
1662
|
+
error instanceof Error ? error.message : String(error);
|
|
1445
1663
|
this.logger.error(
|
|
1446
1664
|
{
|
|
1447
1665
|
sourceChain,
|
|
1448
1666
|
targetChain,
|
|
1449
|
-
amount:
|
|
1667
|
+
amount: targetOutputAmount.toString(),
|
|
1450
1668
|
intentId: intent.id,
|
|
1451
|
-
error:
|
|
1669
|
+
error: errorMessage,
|
|
1452
1670
|
},
|
|
1453
1671
|
'Failed to execute inventory movement',
|
|
1454
1672
|
);
|
|
1455
1673
|
|
|
1456
1674
|
return {
|
|
1457
1675
|
success: false,
|
|
1458
|
-
error:
|
|
1676
|
+
error: errorMessage,
|
|
1459
1677
|
};
|
|
1460
1678
|
}
|
|
1461
1679
|
}
|