@hyperlane-xyz/rebalancer 2.0.0 → 3.0.0
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.d.ts +67 -0
- package/dist/bridges/LiFiBridge.d.ts.map +1 -0
- package/dist/bridges/LiFiBridge.js +386 -0
- package/dist/bridges/LiFiBridge.js.map +1 -0
- package/dist/config/RebalancerConfig.d.ts +7 -2
- package/dist/config/RebalancerConfig.d.ts.map +1 -1
- package/dist/config/RebalancerConfig.js +7 -4
- package/dist/config/RebalancerConfig.js.map +1 -1
- package/dist/config/RebalancerConfig.test.js +134 -1
- package/dist/config/RebalancerConfig.test.js.map +1 -1
- package/dist/config/types.d.ts +1016 -304
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +105 -10
- package/dist/config/types.js.map +1 -1
- package/dist/core/InventoryRebalancer.d.ts +190 -0
- package/dist/core/InventoryRebalancer.d.ts.map +1 -0
- package/dist/core/InventoryRebalancer.js +885 -0
- package/dist/core/InventoryRebalancer.js.map +1 -0
- package/dist/core/InventoryRebalancer.test.d.ts +2 -0
- package/dist/core/InventoryRebalancer.test.d.ts.map +1 -0
- package/dist/core/InventoryRebalancer.test.js +1351 -0
- package/dist/core/InventoryRebalancer.test.js.map +1 -0
- package/dist/core/Rebalancer.d.ts +11 -4
- package/dist/core/Rebalancer.d.ts.map +1 -1
- package/dist/core/Rebalancer.js +92 -9
- package/dist/core/Rebalancer.js.map +1 -1
- package/dist/core/Rebalancer.test.js +82 -49
- package/dist/core/Rebalancer.test.js.map +1 -1
- package/dist/core/RebalancerOrchestrator.d.ts +30 -9
- package/dist/core/RebalancerOrchestrator.d.ts.map +1 -1
- package/dist/core/RebalancerOrchestrator.js +79 -71
- package/dist/core/RebalancerOrchestrator.js.map +1 -1
- package/dist/core/RebalancerOrchestrator.test.d.ts +2 -0
- package/dist/core/RebalancerOrchestrator.test.d.ts.map +1 -0
- package/dist/core/RebalancerOrchestrator.test.js +714 -0
- package/dist/core/RebalancerOrchestrator.test.js.map +1 -0
- package/dist/core/RebalancerService.d.ts +7 -3
- package/dist/core/RebalancerService.d.ts.map +1 -1
- package/dist/core/RebalancerService.js +44 -24
- package/dist/core/RebalancerService.js.map +1 -1
- package/dist/core/RebalancerService.test.js +71 -109
- package/dist/core/RebalancerService.test.js.map +1 -1
- package/dist/e2e/collateral-deficit.e2e-test.js +1 -3
- package/dist/e2e/collateral-deficit.e2e-test.js.map +1 -1
- package/dist/e2e/composite.e2e-test.js.map +1 -1
- package/dist/e2e/harness/BridgeSetup.d.ts +6 -0
- package/dist/e2e/harness/BridgeSetup.d.ts.map +1 -1
- package/dist/e2e/harness/BridgeSetup.js +10 -1
- package/dist/e2e/harness/BridgeSetup.js.map +1 -1
- package/dist/e2e/harness/TestHelpers.d.ts.map +1 -1
- package/dist/e2e/harness/TestHelpers.js +1 -4
- package/dist/e2e/harness/TestHelpers.js.map +1 -1
- package/dist/e2e/harness/TestRebalancer.d.ts +1 -1
- package/dist/e2e/harness/TestRebalancer.d.ts.map +1 -1
- package/dist/e2e/harness/TestRebalancer.js +6 -7
- package/dist/e2e/harness/TestRebalancer.js.map +1 -1
- package/dist/e2e/minAmount.e2e-test.js +0 -1
- package/dist/e2e/minAmount.e2e-test.js.map +1 -1
- package/dist/e2e/weighted.e2e-test.js +0 -1
- package/dist/e2e/weighted.e2e-test.js.map +1 -1
- package/dist/factories/RebalancerContextFactory.d.ts +48 -6
- package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
- package/dist/factories/RebalancerContextFactory.js +170 -17
- package/dist/factories/RebalancerContextFactory.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/IExternalBridge.d.ts +101 -0
- package/dist/interfaces/IExternalBridge.d.ts.map +1 -0
- package/dist/interfaces/IExternalBridge.js +2 -0
- package/dist/interfaces/IExternalBridge.js.map +1 -0
- package/dist/interfaces/IMonitor.d.ts +1 -0
- package/dist/interfaces/IMonitor.d.ts.map +1 -1
- package/dist/interfaces/IRebalancer.d.ts +25 -25
- package/dist/interfaces/IRebalancer.d.ts.map +1 -1
- package/dist/interfaces/IStrategy.d.ts +36 -3
- package/dist/interfaces/IStrategy.d.ts.map +1 -1
- package/dist/interfaces/IStrategy.js +12 -1
- package/dist/interfaces/IStrategy.js.map +1 -1
- package/dist/metrics/PriceGetter.js +1 -1
- package/dist/metrics/PriceGetter.js.map +1 -1
- package/dist/metrics/scripts/metrics.d.ts +3 -3
- package/dist/monitor/Monitor.d.ts +12 -2
- package/dist/monitor/Monitor.d.ts.map +1 -1
- package/dist/monitor/Monitor.js +46 -1
- package/dist/monitor/Monitor.js.map +1 -1
- package/dist/service.js +40 -17
- package/dist/service.js.map +1 -1
- package/dist/strategy/BaseStrategy.d.ts +12 -6
- package/dist/strategy/BaseStrategy.d.ts.map +1 -1
- package/dist/strategy/BaseStrategy.js +56 -21
- package/dist/strategy/BaseStrategy.js.map +1 -1
- package/dist/strategy/CollateralDeficitStrategy.d.ts +1 -1
- package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
- package/dist/strategy/CollateralDeficitStrategy.js +19 -11
- package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
- package/dist/strategy/CollateralDeficitStrategy.test.js +135 -2
- package/dist/strategy/CollateralDeficitStrategy.test.js.map +1 -1
- package/dist/strategy/CompositeStrategy.test.js +13 -0
- package/dist/strategy/CompositeStrategy.test.js.map +1 -1
- package/dist/strategy/MinAmountStrategy.test.js +4 -0
- package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
- package/dist/strategy/StrategyFactory.d.ts +2 -1
- package/dist/strategy/StrategyFactory.d.ts.map +1 -1
- package/dist/strategy/StrategyFactory.js +24 -8
- package/dist/strategy/StrategyFactory.js.map +1 -1
- package/dist/strategy/WeightedStrategy.test.js +6 -0
- package/dist/strategy/WeightedStrategy.test.js.map +1 -1
- package/dist/test/helpers.d.ts +8 -7
- package/dist/test/helpers.d.ts.map +1 -1
- package/dist/test/helpers.js +23 -5
- package/dist/test/helpers.js.map +1 -1
- package/dist/test/lifiMocks.d.ts +51 -0
- package/dist/test/lifiMocks.d.ts.map +1 -0
- package/dist/test/lifiMocks.js +130 -0
- package/dist/test/lifiMocks.js.map +1 -0
- package/dist/tracking/ActionTracker.d.ts +33 -1
- package/dist/tracking/ActionTracker.d.ts.map +1 -1
- package/dist/tracking/ActionTracker.js +193 -22
- package/dist/tracking/ActionTracker.js.map +1 -1
- package/dist/tracking/ActionTracker.test.js +107 -19
- package/dist/tracking/ActionTracker.test.js.map +1 -1
- package/dist/tracking/IActionTracker.d.ts +47 -3
- package/dist/tracking/IActionTracker.d.ts.map +1 -1
- package/dist/tracking/InflightContextAdapter.d.ts.map +1 -1
- package/dist/tracking/InflightContextAdapter.js +24 -7
- package/dist/tracking/InflightContextAdapter.js.map +1 -1
- package/dist/tracking/InflightContextAdapter.test.js +7 -4
- package/dist/tracking/InflightContextAdapter.test.js.map +1 -1
- package/dist/tracking/types.d.ts +31 -2
- package/dist/tracking/types.d.ts.map +1 -1
- package/dist/utils/ExplorerClient.d.ts +2 -1
- package/dist/utils/ExplorerClient.d.ts.map +1 -1
- package/dist/utils/ExplorerClient.js +13 -8
- package/dist/utils/ExplorerClient.js.map +1 -1
- package/dist/utils/bridgeUtils.d.ts +27 -4
- package/dist/utils/bridgeUtils.d.ts.map +1 -1
- package/dist/utils/bridgeUtils.js +38 -0
- package/dist/utils/bridgeUtils.js.map +1 -1
- package/dist/utils/bridgeUtils.test.js +9 -0
- package/dist/utils/bridgeUtils.test.js.map +1 -1
- package/dist/utils/gasEstimation.d.ts +65 -0
- package/dist/utils/gasEstimation.d.ts.map +1 -0
- package/dist/utils/gasEstimation.js +176 -0
- package/dist/utils/gasEstimation.js.map +1 -0
- package/dist/utils/tokenUtils.d.ts +9 -1
- package/dist/utils/tokenUtils.d.ts.map +1 -1
- package/dist/utils/tokenUtils.js +11 -0
- package/dist/utils/tokenUtils.js.map +1 -1
- package/package.json +9 -7
- package/src/bridges/LiFiBridge.ts +538 -0
- package/src/config/RebalancerConfig.test.ts +160 -0
- package/src/config/RebalancerConfig.ts +14 -3
- package/src/config/types.ts +136 -10
- package/src/core/InventoryRebalancer.test.ts +1684 -0
- package/src/core/InventoryRebalancer.ts +1255 -0
- package/src/core/Rebalancer.test.ts +84 -30
- package/src/core/Rebalancer.ts +144 -23
- package/src/core/RebalancerOrchestrator.test.ts +860 -0
- package/src/core/RebalancerOrchestrator.ts +146 -95
- package/src/core/RebalancerService.test.ts +80 -123
- package/src/core/RebalancerService.ts +67 -33
- package/src/e2e/collateral-deficit.e2e-test.ts +2 -4
- package/src/e2e/composite.e2e-test.ts +5 -5
- package/src/e2e/harness/BridgeSetup.ts +28 -1
- package/src/e2e/harness/TestHelpers.ts +1 -4
- package/src/e2e/harness/TestRebalancer.ts +7 -7
- package/src/e2e/minAmount.e2e-test.ts +1 -2
- package/src/e2e/weighted.e2e-test.ts +1 -2
- package/src/factories/RebalancerContextFactory.ts +293 -24
- package/src/index.ts +20 -5
- package/src/interfaces/IExternalBridge.ts +115 -0
- package/src/interfaces/IMonitor.ts +1 -0
- package/src/interfaces/IRebalancer.ts +45 -29
- package/src/interfaces/IStrategy.ts +50 -3
- package/src/metrics/PriceGetter.ts +1 -1
- package/src/monitor/Monitor.ts +81 -2
- package/src/service.ts +59 -18
- package/src/strategy/BaseStrategy.ts +77 -24
- package/src/strategy/CollateralDeficitStrategy.test.ts +181 -4
- package/src/strategy/CollateralDeficitStrategy.ts +42 -15
- package/src/strategy/CompositeStrategy.test.ts +13 -0
- package/src/strategy/MinAmountStrategy.test.ts +4 -0
- package/src/strategy/StrategyFactory.ts +33 -6
- package/src/strategy/WeightedStrategy.test.ts +6 -0
- package/src/test/helpers.ts +39 -14
- package/src/test/lifiMocks.ts +174 -0
- package/src/tracking/ActionTracker.test.ts +122 -19
- package/src/tracking/ActionTracker.ts +284 -24
- package/src/tracking/IActionTracker.ts +58 -3
- package/src/tracking/InflightContextAdapter.test.ts +7 -4
- package/src/tracking/InflightContextAdapter.ts +42 -9
- package/src/tracking/types.ts +43 -2
- package/src/utils/ExplorerClient.ts +23 -10
- package/src/utils/bridgeUtils.test.ts +9 -0
- package/src/utils/bridgeUtils.ts +75 -6
- package/src/utils/gasEstimation.ts +272 -0
- package/src/utils/tokenUtils.ts +12 -0
- package/dist/tracking/index.d.ts +0 -7
- package/dist/tracking/index.d.ts.map +0 -1
- package/dist/tracking/index.js +0 -6
- package/dist/tracking/index.js.map +0 -1
- package/dist/utils/index.d.ts +0 -5
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js +0 -5
- package/dist/utils/index.js.map +0 -1
- package/src/tracking/index.ts +0 -36
- package/src/utils/index.ts +0 -4
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
import { Wallet } from 'ethers';
|
|
2
|
+
import { HyperlaneCore, TOKEN_COLLATERALIZED_STANDARDS, } from '@hyperlane-xyz/sdk';
|
|
3
|
+
import { assert } from '@hyperlane-xyz/utils';
|
|
4
|
+
import { MIN_VIABLE_COST_MULTIPLIER, calculateTransferCosts, } from '../utils/gasEstimation.js';
|
|
5
|
+
import { isNativeTokenStandard } from '../utils/tokenUtils.js';
|
|
6
|
+
/**
|
|
7
|
+
* Buffer percentage to add when bridging inventory.
|
|
8
|
+
* Bridges (amount * (100 + BRIDGE_BUFFER_PERCENT)) / 100 to account for slippage.
|
|
9
|
+
*/
|
|
10
|
+
const BRIDGE_BUFFER_PERCENT = 5n;
|
|
11
|
+
/**
|
|
12
|
+
* Multiplier applied to LiFi's quoted gas costs.
|
|
13
|
+
* LiFi consistently underestimates gas, and gas prices can spike significantly
|
|
14
|
+
* between quote and execution. Using 20x provides headroom for volatility
|
|
15
|
+
* (historically LiFi underestimates by ~14x).
|
|
16
|
+
*/
|
|
17
|
+
const GAS_COST_MULTIPLIER = 20n;
|
|
18
|
+
/**
|
|
19
|
+
* Maximum percentage of inventory that gas costs can consume for a bridge to be viable.
|
|
20
|
+
* If gas exceeds this threshold, the bridge is not economically worthwhile.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_GAS_PERCENT_THRESHOLD = 10n;
|
|
23
|
+
/**
|
|
24
|
+
* Executes inventory-based rebalances for chains that don't support MovableCollateralRouter.
|
|
25
|
+
*
|
|
26
|
+
* IMPORTANT: transferRemote ADDS collateral to the ORIGIN chain (where it's called FROM).
|
|
27
|
+
* So for a strategy route "base (surplus) → arbitrum (deficit)", we must:
|
|
28
|
+
* 1. Ensure inventory is available on the DESTINATION (deficit) chain - arbitrum
|
|
29
|
+
* 2. Call transferRemote FROM arbitrum TO base
|
|
30
|
+
* 3. This ADDS collateral to arbitrum (filling deficit) and releases from base (has surplus)
|
|
31
|
+
*
|
|
32
|
+
* The flow is:
|
|
33
|
+
* 1. Check if inventory is available on the destination (deficit) chain
|
|
34
|
+
* 2. If available, execute transferRemote from destination to origin (swapped direction)
|
|
35
|
+
* 3. If not available, bridge inventory to destination via LiFi, then execute transferRemote
|
|
36
|
+
*
|
|
37
|
+
* Actions created:
|
|
38
|
+
* - `inventory_movement`: LiFi bridge to move inventory to deficit chain
|
|
39
|
+
* - `inventory_deposit`: transferRemote to deposit collateral on deficit chain
|
|
40
|
+
*/
|
|
41
|
+
export class InventoryRebalancer {
|
|
42
|
+
rebalancerType = 'inventory';
|
|
43
|
+
logger;
|
|
44
|
+
config;
|
|
45
|
+
actionTracker;
|
|
46
|
+
externalBridgeRegistry;
|
|
47
|
+
warpCore;
|
|
48
|
+
multiProvider;
|
|
49
|
+
/**
|
|
50
|
+
* Internal balance storage for inventory tracking.
|
|
51
|
+
* Updated via setInventoryBalances() before each rebalance cycle.
|
|
52
|
+
*/
|
|
53
|
+
inventoryBalances = new Map();
|
|
54
|
+
/**
|
|
55
|
+
* Tracks inventory consumed during the current execution cycle.
|
|
56
|
+
* Cleared at the start of each execute() call.
|
|
57
|
+
* Used to prevent over-execution when multiple routes withdraw from the same chain.
|
|
58
|
+
*/
|
|
59
|
+
consumedInventory = new Map();
|
|
60
|
+
constructor(config, actionTracker, externalBridgeRegistry, warpCore, multiProvider, logger) {
|
|
61
|
+
this.config = config;
|
|
62
|
+
this.actionTracker = actionTracker;
|
|
63
|
+
this.externalBridgeRegistry = externalBridgeRegistry;
|
|
64
|
+
this.warpCore = warpCore;
|
|
65
|
+
this.multiProvider = multiProvider;
|
|
66
|
+
this.logger = logger;
|
|
67
|
+
// Validate that all tokens are collateral-backed
|
|
68
|
+
// Synthetic tokens cannot be used with inventory rebalancing because:
|
|
69
|
+
// - transferRemote on synthetics mints new tokens (doesn't transfer collateral)
|
|
70
|
+
// - There's no collateral to deposit/withdraw
|
|
71
|
+
this.validateCollateralBackedTokens();
|
|
72
|
+
this.logger.info({ inventorySigner: config.inventorySigner }, 'InventoryRebalancer initialized');
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Get bridge instance by type from registry.
|
|
76
|
+
* Throws if bridge type not found.
|
|
77
|
+
*/
|
|
78
|
+
getExternalBridge(type) {
|
|
79
|
+
const externalBridge = this.externalBridgeRegistry[type];
|
|
80
|
+
if (!externalBridge) {
|
|
81
|
+
throw new Error(`Bridge type '${type}' not found in registry`);
|
|
82
|
+
}
|
|
83
|
+
return externalBridge;
|
|
84
|
+
}
|
|
85
|
+
getNativeTokenAddress(bridgeType) {
|
|
86
|
+
const bridge = this.getExternalBridge(bridgeType);
|
|
87
|
+
const addr = bridge.getNativeTokenAddress?.();
|
|
88
|
+
if (!addr) {
|
|
89
|
+
throw new Error(`Bridge '${bridge.externalBridgeId}' does not support getNativeTokenAddress()`);
|
|
90
|
+
}
|
|
91
|
+
return addr;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Validate that tokens on inventory chains are collateral-backed.
|
|
95
|
+
* Only checks tokens for chains configured with inventory-based rebalancing.
|
|
96
|
+
* Throws an error if any synthetic tokens are found on inventory chains.
|
|
97
|
+
*/
|
|
98
|
+
validateCollateralBackedTokens() {
|
|
99
|
+
const inventoryChainSet = new Set(this.config.inventoryChains);
|
|
100
|
+
for (const token of this.warpCore.tokens) {
|
|
101
|
+
// Only validate tokens for chains configured for inventory rebalancing
|
|
102
|
+
if (!inventoryChainSet.has(token.chainName)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!TOKEN_COLLATERALIZED_STANDARDS.includes(token.standard)) {
|
|
106
|
+
throw new Error(`InventoryRebalancer cannot be used with synthetic token on chain "${token.chainName}". ` +
|
|
107
|
+
`Token standard "${token.standard}" is not collateral-backed. ` +
|
|
108
|
+
`Only collateral-backed standards are supported: ${TOKEN_COLLATERALIZED_STANDARDS.join(', ')}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get the token for a specific chain from WarpCore.
|
|
114
|
+
*/
|
|
115
|
+
getTokenForChain(chainName) {
|
|
116
|
+
return this.warpCore.tokens.find((t) => t.chainName === chainName);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Set inventory balances from external source.
|
|
120
|
+
* Called before each rebalance cycle to update internal state.
|
|
121
|
+
*/
|
|
122
|
+
setInventoryBalances(balances) {
|
|
123
|
+
this.inventoryBalances = new Map(Object.entries(balances));
|
|
124
|
+
this.logger.debug({
|
|
125
|
+
chains: Array.from(this.inventoryBalances.keys()),
|
|
126
|
+
balances: Object.fromEntries(Array.from(this.inventoryBalances.entries()).map(([chain, balance]) => [chain, balance.toString()])),
|
|
127
|
+
}, 'Updated inventory balances');
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Get available inventory for a chain.
|
|
131
|
+
* Returns 0n for unknown chains.
|
|
132
|
+
*/
|
|
133
|
+
getAvailableInventory(chain) {
|
|
134
|
+
return this.inventoryBalances.get(chain) ?? 0n;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Get all inventory balances.
|
|
138
|
+
*/
|
|
139
|
+
getBalances() {
|
|
140
|
+
return this.inventoryBalances;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Calculate total inventory across all chains, excluding specified chains.
|
|
144
|
+
*/
|
|
145
|
+
getTotalInventory(excludeChains) {
|
|
146
|
+
const excludeSet = new Set(excludeChains);
|
|
147
|
+
let total = 0n;
|
|
148
|
+
for (const [chain, balance] of this.inventoryBalances) {
|
|
149
|
+
if (!excludeSet.has(chain)) {
|
|
150
|
+
total += balance;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return total;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get the effective available inventory for a chain, accounting for
|
|
157
|
+
* inventory already consumed during this execution cycle.
|
|
158
|
+
*
|
|
159
|
+
* This prevents over-execution when multiple routes withdraw from the same chain.
|
|
160
|
+
*
|
|
161
|
+
* @param chain - The chain to check inventory for
|
|
162
|
+
* @returns Effective available inventory (cached - consumed)
|
|
163
|
+
*/
|
|
164
|
+
getEffectiveAvailableInventory(chain) {
|
|
165
|
+
const cached = this.getAvailableInventory(chain);
|
|
166
|
+
const consumed = this.consumedInventory.get(chain) ?? 0n;
|
|
167
|
+
const effective = cached > consumed ? cached - consumed : 0n;
|
|
168
|
+
if (consumed > 0n) {
|
|
169
|
+
this.logger.debug({
|
|
170
|
+
chain,
|
|
171
|
+
cachedInventory: cached.toString(),
|
|
172
|
+
consumedThisCycle: consumed.toString(),
|
|
173
|
+
effectiveInventory: effective.toString(),
|
|
174
|
+
}, 'Calculated effective inventory after prior executions');
|
|
175
|
+
}
|
|
176
|
+
return effective;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Execute inventory-based rebalances for the given routes.
|
|
180
|
+
*
|
|
181
|
+
* Single-intent architecture:
|
|
182
|
+
* 1. Check for existing in_progress intent
|
|
183
|
+
* 2. If exists, continue existing intent (ignores new routes)
|
|
184
|
+
* 3. If not, take only the FIRST route and create a single intent
|
|
185
|
+
*/
|
|
186
|
+
async rebalance(routes) {
|
|
187
|
+
this.consumedInventory.clear();
|
|
188
|
+
// 1. Check for existing in_progress intent
|
|
189
|
+
const activeIntent = await this.getActiveInventoryIntent();
|
|
190
|
+
if (activeIntent) {
|
|
191
|
+
// Continue existing intent, ignore new routes
|
|
192
|
+
this.logger.info({
|
|
193
|
+
intentId: activeIntent.intent.id,
|
|
194
|
+
remaining: activeIntent.remaining.toString(),
|
|
195
|
+
newRoutesIgnored: routes.length,
|
|
196
|
+
}, 'Continuing existing intent, ignoring new routes');
|
|
197
|
+
return this.continueIntent(activeIntent);
|
|
198
|
+
}
|
|
199
|
+
// 2. No existing intent - take first route only
|
|
200
|
+
if (routes.length === 0)
|
|
201
|
+
return [];
|
|
202
|
+
const route = routes[0];
|
|
203
|
+
if (routes.length > 1) {
|
|
204
|
+
this.logger.info({
|
|
205
|
+
selectedRoute: `${route.origin} → ${route.destination}`,
|
|
206
|
+
discardedCount: routes.length - 1,
|
|
207
|
+
}, 'Taking first route only, discarding others');
|
|
208
|
+
}
|
|
209
|
+
// 3. Create intent and execute
|
|
210
|
+
const intent = await this.actionTracker.createRebalanceIntent({
|
|
211
|
+
origin: this.multiProvider.getDomainId(route.origin),
|
|
212
|
+
destination: this.multiProvider.getDomainId(route.destination),
|
|
213
|
+
amount: route.amount,
|
|
214
|
+
executionMethod: 'inventory',
|
|
215
|
+
externalBridge: route.externalBridge,
|
|
216
|
+
});
|
|
217
|
+
this.logger.debug({
|
|
218
|
+
intentId: intent.id,
|
|
219
|
+
origin: route.origin,
|
|
220
|
+
destination: route.destination,
|
|
221
|
+
amount: route.amount.toString(),
|
|
222
|
+
}, 'Created new inventory rebalance intent');
|
|
223
|
+
try {
|
|
224
|
+
const result = await this.executeRoute(route, intent);
|
|
225
|
+
// Update consumed inventory on success
|
|
226
|
+
if (result.success && result.amountSent) {
|
|
227
|
+
const current = this.consumedInventory.get(route.destination) ?? 0n;
|
|
228
|
+
this.consumedInventory.set(route.destination, current + result.amountSent);
|
|
229
|
+
}
|
|
230
|
+
return [result];
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
this.logger.error({
|
|
234
|
+
route,
|
|
235
|
+
intentId: intent.id,
|
|
236
|
+
error: error.message,
|
|
237
|
+
}, 'Failed to execute inventory route');
|
|
238
|
+
return [
|
|
239
|
+
{
|
|
240
|
+
route,
|
|
241
|
+
success: false,
|
|
242
|
+
error: error.message,
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Get the single active inventory intent (if any).
|
|
249
|
+
* Returns null if no in_progress inventory intent exists.
|
|
250
|
+
*/
|
|
251
|
+
async getActiveInventoryIntent() {
|
|
252
|
+
const partialIntents = await this.actionTracker.getPartiallyFulfilledInventoryIntents();
|
|
253
|
+
return partialIntents.length > 0 ? partialIntents[0] : null;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Continue execution of an existing partial intent.
|
|
257
|
+
* Uses the pre-computed remaining amount from PartialInventoryIntent.
|
|
258
|
+
*/
|
|
259
|
+
async continueIntent(partial) {
|
|
260
|
+
const { intent, remaining } = partial;
|
|
261
|
+
const route = {
|
|
262
|
+
origin: this.multiProvider.getChainName(intent.origin),
|
|
263
|
+
destination: this.multiProvider.getChainName(intent.destination),
|
|
264
|
+
amount: remaining,
|
|
265
|
+
executionType: 'inventory',
|
|
266
|
+
externalBridge: intent.externalBridge,
|
|
267
|
+
};
|
|
268
|
+
this.logger.info({
|
|
269
|
+
intentId: intent.id,
|
|
270
|
+
origin: route.origin,
|
|
271
|
+
destination: route.destination,
|
|
272
|
+
remaining: remaining.toString(),
|
|
273
|
+
completed: partial.completedAmount.toString(),
|
|
274
|
+
total: intent.amount.toString(),
|
|
275
|
+
}, 'Continuing partial inventory intent');
|
|
276
|
+
// Warn if intent never started - indicates previous execution attempt failed
|
|
277
|
+
// without creating any actions (e.g., all bridges failed viability check)
|
|
278
|
+
if (intent.status === 'not_started') {
|
|
279
|
+
this.logger.warn({
|
|
280
|
+
intentId: intent.id,
|
|
281
|
+
origin: route.origin,
|
|
282
|
+
destination: route.destination,
|
|
283
|
+
}, 'Retrying intent that never started - previous execution attempt failed without creating any actions');
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const result = await this.executeRoute(route, intent);
|
|
287
|
+
// Update consumed inventory on success
|
|
288
|
+
if (result.success && result.amountSent) {
|
|
289
|
+
const current = this.consumedInventory.get(route.destination) ?? 0n;
|
|
290
|
+
this.consumedInventory.set(route.destination, current + result.amountSent);
|
|
291
|
+
}
|
|
292
|
+
return [result];
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
this.logger.error({
|
|
296
|
+
route,
|
|
297
|
+
intentId: intent.id,
|
|
298
|
+
error: error.message,
|
|
299
|
+
}, 'Failed to continue partial inventory intent');
|
|
300
|
+
return [
|
|
301
|
+
{
|
|
302
|
+
route,
|
|
303
|
+
success: false,
|
|
304
|
+
error: error.message,
|
|
305
|
+
},
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Execute a single inventory route.
|
|
311
|
+
*
|
|
312
|
+
* Strategy provides: origin (surplus) → destination (deficit)
|
|
313
|
+
* "Move collateral FROM origin TO destination"
|
|
314
|
+
*
|
|
315
|
+
* IMPORTANT: transferRemote ADDS collateral to the chain it's called FROM.
|
|
316
|
+
* So to fill the deficit on destination, we must:
|
|
317
|
+
* - Call transferRemote FROM destination TO origin (SWAPPED direction)
|
|
318
|
+
* - This ADDS to destination (deficit filled!) and RELEASES from origin (has surplus)
|
|
319
|
+
*
|
|
320
|
+
* Execution flow:
|
|
321
|
+
* 1. Check inventory on DESTINATION (deficit chain) - need funds there to call transferRemote
|
|
322
|
+
* 2. If low, LiFi bridge TO destination
|
|
323
|
+
* 3. Call transferRemote FROM destination TO origin (swapped)
|
|
324
|
+
*/
|
|
325
|
+
async executeRoute(route, intent) {
|
|
326
|
+
const { origin, destination, amount } = route;
|
|
327
|
+
this.logger.info({
|
|
328
|
+
strategyRoute: `${origin} (surplus) → ${destination} (deficit)`,
|
|
329
|
+
executionRoute: `transferRemote FROM ${destination} TO ${origin}`,
|
|
330
|
+
amount: amount.toString(),
|
|
331
|
+
intentId: intent.id,
|
|
332
|
+
}, 'Executing inventory route');
|
|
333
|
+
// Check available inventory on the DESTINATION (deficit) chain
|
|
334
|
+
// We need inventory here because transferRemote is called FROM this chain
|
|
335
|
+
const availableInventory = this.getEffectiveAvailableInventory(destination);
|
|
336
|
+
this.logger.info({
|
|
337
|
+
checkingChain: destination,
|
|
338
|
+
availableInventory: availableInventory.toString(),
|
|
339
|
+
availableInventoryEth: (Number(availableInventory) / 1e18).toFixed(6),
|
|
340
|
+
requiredAmount: amount.toString(),
|
|
341
|
+
requiredAmountEth: (Number(amount) / 1e18).toFixed(6),
|
|
342
|
+
}, 'Checking effective inventory on destination (deficit) chain');
|
|
343
|
+
// Calculate transfer costs including max transferable and min viable amounts
|
|
344
|
+
// transferRemote is called FROM destination TO origin (swapped direction)
|
|
345
|
+
const costs = await calculateTransferCosts(destination, // FROM chain (where transferRemote is called)
|
|
346
|
+
origin, // TO chain (where Hyperlane message goes)
|
|
347
|
+
availableInventory, amount, this.multiProvider, this.warpCore.multiProvider, this.getTokenForChain.bind(this), this.config.inventorySigner, isNativeTokenStandard, this.logger);
|
|
348
|
+
const { maxTransferable, minViableTransfer } = costs;
|
|
349
|
+
// Calculate total inventory across all chains
|
|
350
|
+
// Note: consumedInventory tracking is handled separately within this cycle
|
|
351
|
+
const totalInventory = this.getTotalInventory([]);
|
|
352
|
+
this.logger.info({
|
|
353
|
+
fromChain: destination,
|
|
354
|
+
toChain: origin,
|
|
355
|
+
availableInventoryEth: (Number(availableInventory) / 1e18).toFixed(6),
|
|
356
|
+
requestedAmountEth: (Number(amount) / 1e18).toFixed(6),
|
|
357
|
+
maxTransferableEth: (Number(maxTransferable) / 1e18).toFixed(6),
|
|
358
|
+
minViableTransferEth: (Number(minViableTransfer) / 1e18).toFixed(6),
|
|
359
|
+
totalInventoryEth: (Number(totalInventory) / 1e18).toFixed(6),
|
|
360
|
+
canFullyFulfill: maxTransferable >= amount,
|
|
361
|
+
canPartialFulfill: maxTransferable >= minViableTransfer,
|
|
362
|
+
}, 'Calculated max transferable amount with cost-based threshold');
|
|
363
|
+
// Early exit: If remaining amount is below minViableTransfer, complete the intent
|
|
364
|
+
// This prevents infinite loops when the remaining amount is too small to economically bridge
|
|
365
|
+
if (amount < minViableTransfer) {
|
|
366
|
+
this.logger.info({
|
|
367
|
+
intentId: intent.id,
|
|
368
|
+
amount: amount.toString(),
|
|
369
|
+
minViableTransfer: minViableTransfer.toString(),
|
|
370
|
+
}, 'Remaining amount below minViableTransfer, completing intent with acceptable loss');
|
|
371
|
+
await this.actionTracker.completeRebalanceIntent(intent.id);
|
|
372
|
+
return {
|
|
373
|
+
route,
|
|
374
|
+
success: true,
|
|
375
|
+
reason: 'completed_with_acceptable_loss',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
// Swap the route for executeTransferRemote: destination → origin
|
|
379
|
+
// This ensures transferRemote is called FROM destination, ADDING collateral there
|
|
380
|
+
const swappedRoute = {
|
|
381
|
+
...route,
|
|
382
|
+
origin: destination, // transferRemote called FROM here
|
|
383
|
+
destination: origin, // Hyperlane message goes TO here
|
|
384
|
+
};
|
|
385
|
+
if (maxTransferable >= amount) {
|
|
386
|
+
// Sufficient inventory on destination - execute transferRemote directly
|
|
387
|
+
const result = await this.executeTransferRemote(swappedRoute, intent, costs.gasQuote);
|
|
388
|
+
// Return original strategy route in result (not the swapped execution route)
|
|
389
|
+
return { ...result, route };
|
|
390
|
+
}
|
|
391
|
+
else if (maxTransferable > 0n && maxTransferable >= minViableTransfer) {
|
|
392
|
+
// Partial transfer: Transfer available inventory when economically viable
|
|
393
|
+
const partialSwappedRoute = {
|
|
394
|
+
...swappedRoute,
|
|
395
|
+
amount: maxTransferable,
|
|
396
|
+
};
|
|
397
|
+
const result = await this.executeTransferRemote(partialSwappedRoute, intent, costs.gasQuote);
|
|
398
|
+
this.logger.info({
|
|
399
|
+
intentId: intent.id,
|
|
400
|
+
partialAmount: maxTransferable.toString(),
|
|
401
|
+
requestedAmount: amount.toString(),
|
|
402
|
+
remainingAmount: (amount - maxTransferable).toString(),
|
|
403
|
+
}, 'Executed partial inventory deposit, remaining will be handled in future cycles');
|
|
404
|
+
// Return original strategy route in result (not the swapped execution route)
|
|
405
|
+
return { ...result, route };
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
// Inventory below cost-based threshold - trigger ExternalBridge movement TO destination chain
|
|
409
|
+
this.logger.info({
|
|
410
|
+
targetChain: destination,
|
|
411
|
+
maxTransferable: maxTransferable.toString(),
|
|
412
|
+
minViableTransfer: minViableTransfer.toString(),
|
|
413
|
+
costMultiplier: MIN_VIABLE_COST_MULTIPLIER.toString(),
|
|
414
|
+
intentId: intent.id,
|
|
415
|
+
}, 'Inventory below cost-based threshold on destination, triggering LiFi movement');
|
|
416
|
+
// Get all available source chains with raw inventory
|
|
417
|
+
const allSources = this.selectAllSourceChains(destination);
|
|
418
|
+
if (allSources.length === 0) {
|
|
419
|
+
this.logger.warn({
|
|
420
|
+
origin,
|
|
421
|
+
destination,
|
|
422
|
+
amount: amount.toString(),
|
|
423
|
+
intentId: intent.id,
|
|
424
|
+
}, 'No inventory available on any monitored chain');
|
|
425
|
+
return {
|
|
426
|
+
route,
|
|
427
|
+
success: false,
|
|
428
|
+
error: 'No inventory available on any monitored chain',
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
// NEW: Calculate max viable amount for each source chain
|
|
432
|
+
// This uses the quote API to determine gas costs upfront
|
|
433
|
+
const viableSources = [];
|
|
434
|
+
for (const source of allSources) {
|
|
435
|
+
const maxViable = await this.calculateMaxViableBridgeAmount(source.chain, destination, source.availableAmount, route.externalBridge);
|
|
436
|
+
if (maxViable > 0n) {
|
|
437
|
+
viableSources.push({ chain: source.chain, maxViable });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// Sort by max viable descending (bridge from largest sources first)
|
|
441
|
+
viableSources.sort((a, b) => (a.maxViable > b.maxViable ? -1 : 1));
|
|
442
|
+
if (viableSources.length === 0) {
|
|
443
|
+
this.logger.warn({
|
|
444
|
+
targetChain: destination,
|
|
445
|
+
sourcesChecked: allSources.length,
|
|
446
|
+
intentId: intent.id,
|
|
447
|
+
}, 'No viable bridge sources - all chains have insufficient inventory or high gas costs');
|
|
448
|
+
return {
|
|
449
|
+
route,
|
|
450
|
+
success: false,
|
|
451
|
+
error: 'No viable bridge sources available',
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
// Create bridge plans using VIABLE amounts (gas already accounted for)
|
|
455
|
+
const targetWithBuffer = ((amount + costs.totalCost) * (100n + BRIDGE_BUFFER_PERCENT)) / 100n;
|
|
456
|
+
const bridgePlans = [];
|
|
457
|
+
let totalPlanned = 0n;
|
|
458
|
+
for (const source of viableSources) {
|
|
459
|
+
if (totalPlanned >= targetWithBuffer)
|
|
460
|
+
break;
|
|
461
|
+
const remaining = targetWithBuffer - totalPlanned;
|
|
462
|
+
const amountFromSource = source.maxViable >= remaining ? remaining : source.maxViable; // Already gas-adjusted!
|
|
463
|
+
bridgePlans.push({
|
|
464
|
+
chain: source.chain,
|
|
465
|
+
amount: amountFromSource,
|
|
466
|
+
});
|
|
467
|
+
totalPlanned += amountFromSource;
|
|
468
|
+
}
|
|
469
|
+
this.logger.info({
|
|
470
|
+
targetChain: destination,
|
|
471
|
+
viableSources: viableSources.map((s) => ({
|
|
472
|
+
chain: s.chain,
|
|
473
|
+
maxViable: s.maxViable.toString(),
|
|
474
|
+
maxViableEth: (Number(s.maxViable) / 1e18).toFixed(6),
|
|
475
|
+
})),
|
|
476
|
+
bridgePlans: bridgePlans.map((p) => ({
|
|
477
|
+
chain: p.chain,
|
|
478
|
+
amount: p.amount.toString(),
|
|
479
|
+
amountEth: (Number(p.amount) / 1e18).toFixed(6),
|
|
480
|
+
})),
|
|
481
|
+
totalPlanned: totalPlanned.toString(),
|
|
482
|
+
targetWithBuffer: targetWithBuffer.toString(),
|
|
483
|
+
intentId: intent.id,
|
|
484
|
+
}, 'Created bridge plans using gas-adjusted viable amounts');
|
|
485
|
+
// Execute all bridges in parallel
|
|
486
|
+
const bridgeResults = await Promise.allSettled(bridgePlans.map((plan) => this.executeInventoryMovement(plan.chain, destination, plan.amount, intent, route.externalBridge)));
|
|
487
|
+
// Process results
|
|
488
|
+
let successCount = 0;
|
|
489
|
+
let totalBridged = 0n;
|
|
490
|
+
const failedErrors = [];
|
|
491
|
+
for (let i = 0; i < bridgeResults.length; i++) {
|
|
492
|
+
const result = bridgeResults[i];
|
|
493
|
+
const plan = bridgePlans[i];
|
|
494
|
+
if (result.status === 'fulfilled' && result.value.success) {
|
|
495
|
+
successCount++;
|
|
496
|
+
totalBridged += plan.amount;
|
|
497
|
+
this.logger.info({
|
|
498
|
+
sourceChain: plan.chain,
|
|
499
|
+
amount: plan.amount.toString(),
|
|
500
|
+
txHash: result.value.txHash,
|
|
501
|
+
}, 'Inventory movement succeeded');
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
const error = result.status === 'rejected'
|
|
505
|
+
? result.reason?.message
|
|
506
|
+
: result.value.error;
|
|
507
|
+
if (error) {
|
|
508
|
+
failedErrors.push(`${plan.chain}: ${error}`);
|
|
509
|
+
}
|
|
510
|
+
this.logger.warn({
|
|
511
|
+
sourceChain: plan.chain,
|
|
512
|
+
amount: plan.amount.toString(),
|
|
513
|
+
error,
|
|
514
|
+
}, 'Inventory movement failed');
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (successCount === 0) {
|
|
518
|
+
const errorDetails = failedErrors.length > 0 ? ` (${failedErrors.join('; ')})` : '';
|
|
519
|
+
return {
|
|
520
|
+
route,
|
|
521
|
+
success: false,
|
|
522
|
+
error: `All inventory movements failed${errorDetails}`,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
this.logger.info({
|
|
526
|
+
targetChain: destination,
|
|
527
|
+
successCount,
|
|
528
|
+
totalBridged: totalBridged.toString(),
|
|
529
|
+
targetAmount: amount.toString(),
|
|
530
|
+
intentId: intent.id,
|
|
531
|
+
}, 'Parallel inventory movements completed, transferRemote will execute after bridges complete');
|
|
532
|
+
return { route, success: true };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Execute a transferRemote to deposit collateral.
|
|
537
|
+
*
|
|
538
|
+
* IMPORTANT: The route passed here has SWAPPED direction from the strategy route.
|
|
539
|
+
* - route.origin = the deficit chain (where transferRemote is called FROM)
|
|
540
|
+
* - route.destination = the surplus chain (where Hyperlane message goes TO)
|
|
541
|
+
*
|
|
542
|
+
* transferRemote mechanics:
|
|
543
|
+
* - Calls _transferFromSender() which ADDS collateral to route.origin
|
|
544
|
+
* - Sends Hyperlane message to route.destination to RELEASE collateral
|
|
545
|
+
*
|
|
546
|
+
* @param route - The transfer route (swapped direction)
|
|
547
|
+
* @param intent - The rebalance intent being executed
|
|
548
|
+
* @param gasQuote - Pre-calculated gas quote from calculateTransferCosts
|
|
549
|
+
*/
|
|
550
|
+
async executeTransferRemote(route, intent, gasQuote) {
|
|
551
|
+
const { origin, destination, amount } = route;
|
|
552
|
+
const originToken = this.getTokenForChain(origin);
|
|
553
|
+
if (!originToken) {
|
|
554
|
+
throw new Error(`No token found for origin chain: ${origin}`);
|
|
555
|
+
}
|
|
556
|
+
const destinationDomain = this.multiProvider.getDomainId(destination);
|
|
557
|
+
// Get the hyperlane adapter for the token
|
|
558
|
+
const adapter = originToken.getHypAdapter(this.warpCore.multiProvider);
|
|
559
|
+
this.logger.debug({
|
|
560
|
+
origin,
|
|
561
|
+
destination,
|
|
562
|
+
amount: amount.toString(),
|
|
563
|
+
gasQuote: {
|
|
564
|
+
igpQuote: gasQuote.igpQuote.amount.toString(),
|
|
565
|
+
tokenFeeQuote: gasQuote.tokenFeeQuote?.amount?.toString() ?? 'none',
|
|
566
|
+
},
|
|
567
|
+
}, 'Using pre-calculated gas quote for transferRemote');
|
|
568
|
+
// Populate the transferRemote transaction
|
|
569
|
+
const populatedTx = await adapter.populateTransferRemoteTx({
|
|
570
|
+
destination: destinationDomain,
|
|
571
|
+
recipient: this.config.inventorySigner,
|
|
572
|
+
weiAmountOrId: amount,
|
|
573
|
+
interchainGas: gasQuote,
|
|
574
|
+
});
|
|
575
|
+
// Send the transaction using inventory MultiProvider if available
|
|
576
|
+
this.logger.info({
|
|
577
|
+
origin,
|
|
578
|
+
destination,
|
|
579
|
+
amount: amount.toString(),
|
|
580
|
+
intentId: intent.id,
|
|
581
|
+
}, 'Sending transferRemote transaction');
|
|
582
|
+
// Use inventoryMultiProvider if available, otherwise fall back to multiProvider
|
|
583
|
+
const signingProvider = this.config.inventoryMultiProvider ?? this.multiProvider;
|
|
584
|
+
// Get reorgPeriod for confirmation waiting
|
|
585
|
+
const reorgPeriod = this.multiProvider.getChainMetadata(origin).blocks?.reorgPeriod ?? 32;
|
|
586
|
+
// Wait for reorgPeriod confirmations via SDK to ensure Monitor sees balance changes
|
|
587
|
+
const receipt = await signingProvider.sendTransaction(origin, populatedTx, {
|
|
588
|
+
waitConfirmations: reorgPeriod,
|
|
589
|
+
});
|
|
590
|
+
// Extract messageId from the transaction receipt logs
|
|
591
|
+
const dispatchedMessages = HyperlaneCore.getDispatchedMessages(receipt);
|
|
592
|
+
const messageId = dispatchedMessages[0]?.id;
|
|
593
|
+
if (!messageId) {
|
|
594
|
+
this.logger.warn({
|
|
595
|
+
origin,
|
|
596
|
+
destination,
|
|
597
|
+
txHash: receipt.transactionHash,
|
|
598
|
+
intentId: intent.id,
|
|
599
|
+
}, 'TransferRemote transaction sent but no messageId found in logs');
|
|
600
|
+
}
|
|
601
|
+
this.logger.info({
|
|
602
|
+
origin,
|
|
603
|
+
destination,
|
|
604
|
+
txHash: receipt.transactionHash,
|
|
605
|
+
messageId,
|
|
606
|
+
intentId: intent.id,
|
|
607
|
+
}, 'TransferRemote transaction confirmed');
|
|
608
|
+
// Create the inventory_deposit action with messageId for tracking
|
|
609
|
+
await this.actionTracker.createRebalanceAction({
|
|
610
|
+
intentId: intent.id,
|
|
611
|
+
origin: this.multiProvider.getDomainId(origin),
|
|
612
|
+
destination: destinationDomain,
|
|
613
|
+
amount,
|
|
614
|
+
type: 'inventory_deposit',
|
|
615
|
+
txHash: receipt.transactionHash,
|
|
616
|
+
messageId,
|
|
617
|
+
});
|
|
618
|
+
return {
|
|
619
|
+
route,
|
|
620
|
+
success: true,
|
|
621
|
+
amountSent: amount,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Select all source chains with available inventory for bridging.
|
|
626
|
+
* Returns sources sorted by available amount (highest first).
|
|
627
|
+
*/
|
|
628
|
+
selectAllSourceChains(targetChain) {
|
|
629
|
+
const balances = this.getBalances();
|
|
630
|
+
const sources = [];
|
|
631
|
+
for (const [chainName, balance] of balances) {
|
|
632
|
+
if (chainName === targetChain)
|
|
633
|
+
continue;
|
|
634
|
+
const consumed = this.consumedInventory.get(chainName) ?? 0n;
|
|
635
|
+
const effectiveAvailable = balance > consumed ? balance - consumed : 0n;
|
|
636
|
+
if (effectiveAvailable > 0n) {
|
|
637
|
+
sources.push({
|
|
638
|
+
chain: chainName,
|
|
639
|
+
availableAmount: effectiveAvailable,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// Sort by available amount descending (bridge from largest sources first)
|
|
644
|
+
return sources.sort((a, b) => a.availableAmount > b.availableAmount ? -1 : 1);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Calculate the maximum amount that can be bridged from a source chain.
|
|
648
|
+
* Uses LiFi quote to determine gas costs, applies 20x multiplier buffer.
|
|
649
|
+
* Returns 0 if gas exceeds 10% of inventory (not economically viable).
|
|
650
|
+
*
|
|
651
|
+
* This is the key method for the gas-aware planning approach:
|
|
652
|
+
* - Gets a quote for the full raw inventory to determine actual gas costs
|
|
653
|
+
* - Applies conservative 20x buffer (LiFi underestimates by ~14x historically)
|
|
654
|
+
* - Returns 0 if gas > 10% of inventory (not worth bridging)
|
|
655
|
+
* - Returns inventory - estimatedGas if viable
|
|
656
|
+
*
|
|
657
|
+
* @param sourceChain - Chain to bridge from
|
|
658
|
+
* @param targetChain - Chain to bridge to
|
|
659
|
+
* @param rawInventory - Raw available inventory on source chain
|
|
660
|
+
* @param externalBridgeType - External bridge type to use
|
|
661
|
+
* @returns Maximum viable bridge amount (0 if not viable)
|
|
662
|
+
*/
|
|
663
|
+
async calculateMaxViableBridgeAmount(sourceChain, targetChain, rawInventory, externalBridgeType) {
|
|
664
|
+
const sourceToken = this.getTokenForChain(sourceChain);
|
|
665
|
+
const targetToken = this.getTokenForChain(targetChain);
|
|
666
|
+
if (!sourceToken || !targetToken)
|
|
667
|
+
return 0n;
|
|
668
|
+
// Only applies to native tokens (need gas from same balance)
|
|
669
|
+
if (!isNativeTokenStandard(sourceToken.standard)) {
|
|
670
|
+
return rawInventory; // ERC20s don't compete with gas
|
|
671
|
+
}
|
|
672
|
+
// Convert HypNative token addresses to LiFi's native ETH representation
|
|
673
|
+
const fromTokenAddress = this.getNativeTokenAddress(externalBridgeType);
|
|
674
|
+
const toTokenAddress = isNativeTokenStandard(targetToken.standard)
|
|
675
|
+
? this.getNativeTokenAddress(externalBridgeType)
|
|
676
|
+
: targetToken.addressOrDenom;
|
|
677
|
+
const sourceChainId = Number(this.multiProvider.getChainId(sourceChain));
|
|
678
|
+
const targetChainId = Number(this.multiProvider.getChainId(targetChain));
|
|
679
|
+
try {
|
|
680
|
+
const exteralBridge = this.getExternalBridge(externalBridgeType);
|
|
681
|
+
const quote = await exteralBridge.quote({
|
|
682
|
+
fromChain: sourceChainId,
|
|
683
|
+
toChain: targetChainId,
|
|
684
|
+
fromToken: fromTokenAddress,
|
|
685
|
+
toToken: toTokenAddress,
|
|
686
|
+
fromAmount: rawInventory,
|
|
687
|
+
fromAddress: this.config.inventorySigner,
|
|
688
|
+
toAddress: this.config.inventorySigner,
|
|
689
|
+
});
|
|
690
|
+
// Apply 20x multiplier on quoted gas (LiFi underestimates by ~14x)
|
|
691
|
+
const estimatedGas = quote.gasCosts * GAS_COST_MULTIPLIER;
|
|
692
|
+
// Viability check: gas should not exceed 10% of inventory
|
|
693
|
+
const maxGasThreshold = rawInventory / MAX_GAS_PERCENT_THRESHOLD;
|
|
694
|
+
if (estimatedGas > maxGasThreshold) {
|
|
695
|
+
this.logger.info({
|
|
696
|
+
sourceChain,
|
|
697
|
+
targetChain,
|
|
698
|
+
rawInventory: rawInventory.toString(),
|
|
699
|
+
rawInventoryEth: (Number(rawInventory) / 1e18).toFixed(6),
|
|
700
|
+
quotedGas: quote.gasCosts.toString(),
|
|
701
|
+
estimatedGas: estimatedGas.toString(),
|
|
702
|
+
estimatedGasEth: (Number(estimatedGas) / 1e18).toFixed(6),
|
|
703
|
+
maxGasThreshold: maxGasThreshold.toString(),
|
|
704
|
+
gasPercent: `${(Number(estimatedGas) * 100) / Number(rawInventory)}%`,
|
|
705
|
+
}, 'Bridge not viable - gas cost exceeds 10% of inventory');
|
|
706
|
+
return 0n;
|
|
707
|
+
}
|
|
708
|
+
// Max viable = inventory minus estimated gas
|
|
709
|
+
const maxViable = rawInventory - estimatedGas;
|
|
710
|
+
this.logger.info({
|
|
711
|
+
sourceChain,
|
|
712
|
+
targetChain,
|
|
713
|
+
rawInventory: rawInventory.toString(),
|
|
714
|
+
rawInventoryEth: (Number(rawInventory) / 1e18).toFixed(6),
|
|
715
|
+
quotedGas: quote.gasCosts.toString(),
|
|
716
|
+
estimatedGas: estimatedGas.toString(),
|
|
717
|
+
estimatedGasEth: (Number(estimatedGas) / 1e18).toFixed(6),
|
|
718
|
+
maxViable: maxViable.toString(),
|
|
719
|
+
maxViableEth: (Number(maxViable) / 1e18).toFixed(6),
|
|
720
|
+
}, 'Calculated max viable bridge amount');
|
|
721
|
+
return maxViable;
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
this.logger.warn({
|
|
725
|
+
sourceChain,
|
|
726
|
+
targetChain,
|
|
727
|
+
error: error.message,
|
|
728
|
+
}, 'Failed to calculate max viable bridge amount, skipping chain');
|
|
729
|
+
return 0n;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Execute inventory movement from source chain to target chain via LiFi bridge.
|
|
734
|
+
*
|
|
735
|
+
* IMPORTANT: The amount parameter is now the MAX VIABLE amount (gas already subtracted
|
|
736
|
+
* by calculateMaxViableBridgeAmount). This method trusts that the amount is pre-validated.
|
|
737
|
+
*
|
|
738
|
+
* @param sourceChain - Chain to move inventory from
|
|
739
|
+
* @param targetChain - Chain to move inventory to (origin chain for rebalancing)
|
|
740
|
+
* @param amount - Pre-validated amount to bridge (gas already accounted for)
|
|
741
|
+
* @param intent - Rebalance intent for tracking
|
|
742
|
+
* @param externalBridgeType - External bridge type to use
|
|
743
|
+
* @returns Result with success status and optional txHash/error
|
|
744
|
+
*/
|
|
745
|
+
async executeInventoryMovement(sourceChain, targetChain, amount, intent, externalBridgeType) {
|
|
746
|
+
const sourceToken = this.getTokenForChain(sourceChain);
|
|
747
|
+
if (!sourceToken) {
|
|
748
|
+
return {
|
|
749
|
+
success: false,
|
|
750
|
+
error: `No token found for source chain: ${sourceChain}`,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
const targetToken = this.getTokenForChain(targetChain);
|
|
754
|
+
if (!targetToken) {
|
|
755
|
+
return {
|
|
756
|
+
success: false,
|
|
757
|
+
error: `No token found for target chain: ${targetChain}`,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
// Get chain IDs for the external bridge (not domain IDs)
|
|
761
|
+
// Convert to number since getChainId can return string | number
|
|
762
|
+
const sourceChainId = Number(this.multiProvider.getChainId(sourceChain));
|
|
763
|
+
const targetChainId = Number(this.multiProvider.getChainId(targetChain));
|
|
764
|
+
// Convert HypNative token addresses to LiFi's native ETH representation
|
|
765
|
+
// For HypNative tokens, addressOrDenom is the warp route contract, not the native token
|
|
766
|
+
const fromTokenAddress = isNativeTokenStandard(sourceToken.standard)
|
|
767
|
+
? this.getNativeTokenAddress(externalBridgeType)
|
|
768
|
+
: sourceToken.addressOrDenom;
|
|
769
|
+
const toTokenAddress = isNativeTokenStandard(targetToken.standard)
|
|
770
|
+
? this.getNativeTokenAddress(externalBridgeType)
|
|
771
|
+
: targetToken.addressOrDenom;
|
|
772
|
+
this.logger.debug({
|
|
773
|
+
sourceTokenStandard: sourceToken.standard,
|
|
774
|
+
targetTokenStandard: targetToken.standard,
|
|
775
|
+
fromTokenAddress,
|
|
776
|
+
toTokenAddress,
|
|
777
|
+
}, 'Resolved token addresses for LiFi bridge');
|
|
778
|
+
// Calculate minViableTransfer for the target chain
|
|
779
|
+
// If bridging less than this, the received amount won't be enough to execute transferRemote
|
|
780
|
+
// So we over-bridge to ensure we can complete the intent in the next cycle
|
|
781
|
+
const costs = await calculateTransferCosts(targetChain, // FROM chain for transferRemote (the target of this bridge)
|
|
782
|
+
sourceChain, // TO chain for transferRemote (Hyperlane message destination)
|
|
783
|
+
amount, // availableInventory (not used for minViableTransfer calculation)
|
|
784
|
+
amount, // requestedAmount
|
|
785
|
+
this.multiProvider, this.warpCore.multiProvider, this.getTokenForChain.bind(this), this.config.inventorySigner, isNativeTokenStandard, this.logger);
|
|
786
|
+
const { minViableTransfer } = costs;
|
|
787
|
+
// If the requested amount is below minViableTransfer, adjust it up
|
|
788
|
+
// This ensures we bridge enough to actually complete the final transferRemote
|
|
789
|
+
const effectiveAmount = amount < minViableTransfer ? minViableTransfer : amount;
|
|
790
|
+
if (effectiveAmount !== amount) {
|
|
791
|
+
this.logger.info({
|
|
792
|
+
originalAmount: amount.toString(),
|
|
793
|
+
effectiveAmount: effectiveAmount.toString(),
|
|
794
|
+
minViableTransfer: minViableTransfer.toString(),
|
|
795
|
+
originalAmountEth: (Number(amount) / 1e18).toFixed(6),
|
|
796
|
+
effectiveAmountEth: (Number(effectiveAmount) / 1e18).toFixed(6),
|
|
797
|
+
minViableTransferEth: (Number(minViableTransfer) / 1e18).toFixed(6),
|
|
798
|
+
adjustedUp: true,
|
|
799
|
+
intentId: intent.id,
|
|
800
|
+
}, 'Over-bridging to minViableTransfer to ensure final transferRemote can complete');
|
|
801
|
+
}
|
|
802
|
+
try {
|
|
803
|
+
const externalBridge = this.getExternalBridge(externalBridgeType);
|
|
804
|
+
const quote = await externalBridge.quote({
|
|
805
|
+
fromChain: sourceChainId,
|
|
806
|
+
toChain: targetChainId,
|
|
807
|
+
fromToken: fromTokenAddress,
|
|
808
|
+
toToken: toTokenAddress,
|
|
809
|
+
fromAmount: effectiveAmount,
|
|
810
|
+
fromAddress: this.config.inventorySigner,
|
|
811
|
+
toAddress: this.config.inventorySigner,
|
|
812
|
+
});
|
|
813
|
+
const inputRequired = quote.fromAmount;
|
|
814
|
+
this.logger.info({
|
|
815
|
+
sourceChain,
|
|
816
|
+
targetChain,
|
|
817
|
+
sourceChainId,
|
|
818
|
+
targetChainId,
|
|
819
|
+
preValidatedAmount: amount.toString(),
|
|
820
|
+
preValidatedAmountEth: (Number(amount) / 1e18).toFixed(6),
|
|
821
|
+
effectiveAmount: effectiveAmount.toString(),
|
|
822
|
+
effectiveAmountEth: (Number(effectiveAmount) / 1e18).toFixed(6),
|
|
823
|
+
inputRequired: inputRequired.toString(),
|
|
824
|
+
expectedOutput: quote.toAmount.toString(),
|
|
825
|
+
expectedOutputEth: (Number(quote.toAmount) / 1e18).toFixed(6),
|
|
826
|
+
gasCosts: quote.gasCosts.toString(),
|
|
827
|
+
feeCosts: quote.feeCosts.toString(),
|
|
828
|
+
intentId: intent.id,
|
|
829
|
+
adjustedForMinViable: effectiveAmount > amount,
|
|
830
|
+
}, 'Executing inventory movement via LiFi with pre-validated amount');
|
|
831
|
+
this.logger.debug({
|
|
832
|
+
quoteId: quote.id,
|
|
833
|
+
tool: quote.tool,
|
|
834
|
+
fromAmount: quote.fromAmount.toString(),
|
|
835
|
+
toAmount: quote.toAmount.toString(),
|
|
836
|
+
toAmountMin: quote.toAmountMin.toString(),
|
|
837
|
+
executionDuration: quote.executionDuration,
|
|
838
|
+
gasCosts: quote.gasCosts.toString(),
|
|
839
|
+
feeCosts: quote.feeCosts.toString(),
|
|
840
|
+
}, 'Received LiFi quote for inventory movement');
|
|
841
|
+
const signingProvider = this.config.inventoryMultiProvider ?? this.multiProvider;
|
|
842
|
+
const signer = signingProvider.getSigner(sourceChain);
|
|
843
|
+
assert(signer instanceof Wallet, `External bridge execution requires a Wallet signer with private key access, got ${signer.constructor.name}`);
|
|
844
|
+
const result = await externalBridge.execute(quote, signer.privateKey);
|
|
845
|
+
this.logger.info({
|
|
846
|
+
sourceChain,
|
|
847
|
+
targetChain,
|
|
848
|
+
txHash: result.txHash,
|
|
849
|
+
intentId: intent.id,
|
|
850
|
+
}, 'Inventory movement transaction executed');
|
|
851
|
+
await this.actionTracker.createRebalanceAction({
|
|
852
|
+
intentId: intent.id,
|
|
853
|
+
origin: this.multiProvider.getDomainId(sourceChain),
|
|
854
|
+
destination: this.multiProvider.getDomainId(targetChain),
|
|
855
|
+
amount: inputRequired,
|
|
856
|
+
type: 'inventory_movement',
|
|
857
|
+
txHash: result.txHash,
|
|
858
|
+
externalBridgeId: externalBridgeType,
|
|
859
|
+
});
|
|
860
|
+
// Track consumed inventory on source chain for this cycle
|
|
861
|
+
const currentConsumed = this.consumedInventory.get(sourceChain) ?? 0n;
|
|
862
|
+
this.consumedInventory.set(sourceChain, currentConsumed + inputRequired);
|
|
863
|
+
this.logger.debug({
|
|
864
|
+
sourceChain,
|
|
865
|
+
amountConsumed: inputRequired.toString(),
|
|
866
|
+
totalConsumed: (currentConsumed + inputRequired).toString(),
|
|
867
|
+
}, 'Updated consumed inventory after LiFi bridge');
|
|
868
|
+
return { success: true, txHash: result.txHash };
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
this.logger.error({
|
|
872
|
+
sourceChain,
|
|
873
|
+
targetChain,
|
|
874
|
+
amount: amount.toString(),
|
|
875
|
+
intentId: intent.id,
|
|
876
|
+
error: error.message,
|
|
877
|
+
}, 'Failed to execute inventory movement');
|
|
878
|
+
return {
|
|
879
|
+
success: false,
|
|
880
|
+
error: error.message,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
//# sourceMappingURL=InventoryRebalancer.js.map
|