@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
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { HyperlaneCore,
|
|
2
|
-
import { ProtocolType, assert, ensure0x,
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
requiredAmount:
|
|
365
|
-
|
|
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,
|
|
372
|
-
const {
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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 (
|
|
484
|
+
if (requestedLocalAmount < minViableTransfer) {
|
|
390
485
|
this.logger.info({
|
|
391
486
|
intentId: intent.id,
|
|
392
|
-
amount:
|
|
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 >=
|
|
505
|
+
if (maxTransferable >= requestedLocalAmount) {
|
|
410
506
|
// Sufficient inventory on destination - execute transferRemote directly
|
|
411
|
-
const
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, '
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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,
|
|
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
|
|
757
|
-
* Uses LiFi
|
|
758
|
-
*
|
|
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
|
-
*
|
|
767
|
-
*
|
|
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
|
|
874
|
+
async calculateBridgeCapacity(sourceChain, targetChain, rawInventory, externalBridgeType) {
|
|
773
875
|
const sourceToken = this.getTokenForChain(sourceChain);
|
|
774
876
|
const targetToken = this.getTokenForChain(targetChain);
|
|
775
|
-
|
|
776
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
}
|
|
813
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
}
|
|
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
|
|
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
|
-
*
|
|
843
|
-
*
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
928
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
1109
|
+
amount: targetOutputAmount.toString(),
|
|
984
1110
|
intentId: intent.id,
|
|
985
|
-
error:
|
|
1111
|
+
error: errorMessage,
|
|
986
1112
|
}, 'Failed to execute inventory movement');
|
|
987
1113
|
return {
|
|
988
1114
|
success: false,
|
|
989
|
-
error:
|
|
1115
|
+
error: errorMessage,
|
|
990
1116
|
};
|
|
991
1117
|
}
|
|
992
1118
|
}
|