@strkfarm/sdk 2.0.0-dev.27 → 2.0.0-dev.28
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/cli.js +190 -36
- package/dist/cli.mjs +188 -34
- package/dist/index.browser.global.js +79130 -49357
- package/dist/index.browser.mjs +18039 -11434
- package/dist/index.d.ts +2869 -898
- package/dist/index.js +19036 -12210
- package/dist/index.mjs +18942 -12161
- package/package.json +1 -1
- package/src/data/avnu.abi.json +840 -0
- package/src/data/ekubo-price-fethcer.abi.json +265 -0
- package/src/dataTypes/_bignumber.ts +13 -4
- package/src/dataTypes/index.ts +3 -2
- package/src/dataTypes/mynumber.ts +141 -0
- package/src/global.ts +76 -41
- package/src/index.browser.ts +2 -1
- package/src/interfaces/common.tsx +167 -2
- package/src/modules/ExtendedWrapperSDk/types.ts +26 -4
- package/src/modules/ExtendedWrapperSDk/wrapper.ts +110 -67
- package/src/modules/apollo-client-config.ts +28 -0
- package/src/modules/avnu.ts +4 -4
- package/src/modules/ekubo-pricer.ts +79 -0
- package/src/modules/ekubo-quoter.ts +46 -30
- package/src/modules/erc20.ts +17 -0
- package/src/modules/harvests.ts +43 -29
- package/src/modules/pragma.ts +23 -8
- package/src/modules/pricer-from-api.ts +156 -15
- package/src/modules/pricer-lst.ts +1 -1
- package/src/modules/pricer.ts +40 -4
- package/src/modules/pricerBase.ts +2 -1
- package/src/node/deployer.ts +36 -1
- package/src/node/pricer-redis.ts +2 -1
- package/src/strategies/base-strategy.ts +78 -10
- package/src/strategies/ekubo-cl-vault.tsx +906 -347
- package/src/strategies/factory.ts +159 -0
- package/src/strategies/index.ts +6 -1
- package/src/strategies/registry.ts +239 -0
- package/src/strategies/sensei.ts +335 -7
- package/src/strategies/svk-strategy.ts +97 -27
- package/src/strategies/types.ts +4 -0
- package/src/strategies/universal-adapters/adapter-utils.ts +2 -1
- package/src/strategies/universal-adapters/avnu-adapter.ts +177 -268
- package/src/strategies/universal-adapters/baseAdapter.ts +263 -251
- package/src/strategies/universal-adapters/common-adapter.ts +206 -203
- package/src/strategies/universal-adapters/extended-adapter.ts +155 -336
- package/src/strategies/universal-adapters/index.ts +9 -8
- package/src/strategies/universal-adapters/token-transfer-adapter.ts +200 -0
- package/src/strategies/universal-adapters/usdc<>usdce-adapter.ts +200 -0
- package/src/strategies/universal-adapters/vesu-adapter.ts +110 -75
- package/src/strategies/universal-adapters/vesu-modify-position-adapter.ts +476 -0
- package/src/strategies/universal-adapters/vesu-multiply-adapter.ts +762 -844
- package/src/strategies/universal-adapters/vesu-position-common.ts +251 -0
- package/src/strategies/universal-adapters/vesu-supply-only-adapter.ts +18 -3
- package/src/strategies/universal-lst-muliplier-strategy.tsx +396 -204
- package/src/strategies/universal-strategy.tsx +1426 -1178
- package/src/strategies/vesu-extended-strategy/services/executionService.ts +2251 -0
- package/src/strategies/vesu-extended-strategy/services/extended-vesu-state-manager.ts +2941 -0
- package/src/strategies/vesu-extended-strategy/services/operationService.ts +12 -1
- package/src/strategies/vesu-extended-strategy/types/transaction-metadata.ts +52 -0
- package/src/strategies/vesu-extended-strategy/utils/config.runtime.ts +1 -0
- package/src/strategies/vesu-extended-strategy/utils/constants.ts +2 -0
- package/src/strategies/vesu-extended-strategy/utils/helper.ts +158 -124
- package/src/strategies/vesu-extended-strategy/vesu-extended-strategy.tsx +377 -1788
- package/src/strategies/vesu-rebalance.tsx +255 -152
- package/src/utils/health-factor-math.ts +4 -1
- package/src/utils/index.ts +2 -1
- package/src/utils/logger.browser.ts +22 -4
- package/src/utils/logger.node.ts +259 -24
- package/src/utils/starknet-call-parser.ts +1036 -0
- package/src/utils/strategy-utils.ts +61 -0
- package/src/strategies/universal-adapters/unused-balance-adapter.ts +0 -109
|
@@ -0,0 +1,2251 @@
|
|
|
1
|
+
import { Account, Call, uint256 } from "starknet";
|
|
2
|
+
import { ContractAddr, Web3Number } from "@/dataTypes";
|
|
3
|
+
import { IConfig, TokenInfo, Protocols } from "@/interfaces";
|
|
4
|
+
import { PricerBase } from "@/modules/pricerBase";
|
|
5
|
+
import { ERC20 } from "@/modules";
|
|
6
|
+
import { logger, StandardMerkleTree, StarknetCallParser } from "@/utils";
|
|
7
|
+
import { ExtendedAdapter } from "../../universal-adapters/extended-adapter";
|
|
8
|
+
import { VesuMultiplyAdapter } from "../../universal-adapters/vesu-multiply-adapter";
|
|
9
|
+
import {
|
|
10
|
+
VesuModifyPositionAdapter,
|
|
11
|
+
VesuModifyPositionDepositParams,
|
|
12
|
+
VesuModifyPositionWithdrawParams,
|
|
13
|
+
} from "../../universal-adapters/vesu-modify-position-adapter";
|
|
14
|
+
import { AvnuAdapter } from "../../universal-adapters/avnu-adapter";
|
|
15
|
+
import { UsdcToUsdceAdapter } from "../../universal-adapters/usdc<>usdce-adapter";
|
|
16
|
+
import { ManageCall, SwapPriceInfo } from "../../universal-adapters/baseAdapter";
|
|
17
|
+
import { OpenOrder, OrderSide, OrderStatus } from "@/modules/ExtendedWrapperSDk";
|
|
18
|
+
import {
|
|
19
|
+
SolveResult,
|
|
20
|
+
ExecutionRoute,
|
|
21
|
+
RouteType,
|
|
22
|
+
SolveCaseEntry,
|
|
23
|
+
TransferRoute,
|
|
24
|
+
SwapRoute,
|
|
25
|
+
VesuMultiplyRoute,
|
|
26
|
+
VesuDebtRoute,
|
|
27
|
+
RealisePnlRoute,
|
|
28
|
+
ExtendedLeverRoute,
|
|
29
|
+
CrisisBorrowRoute,
|
|
30
|
+
BringLiquidityRoute,
|
|
31
|
+
routeSummary,
|
|
32
|
+
} from "./extended-vesu-state-manager";
|
|
33
|
+
import {
|
|
34
|
+
CycleType,
|
|
35
|
+
TransactionResult,
|
|
36
|
+
ExecutionEventType,
|
|
37
|
+
ExecutionEventMetadata,
|
|
38
|
+
ExecutionCallback,
|
|
39
|
+
} from "../types/transaction-metadata";
|
|
40
|
+
import {
|
|
41
|
+
calculatePositionToCloseToWithdrawAmount,
|
|
42
|
+
calculateExtendedLevergae,
|
|
43
|
+
} from "../utils/helper";
|
|
44
|
+
import { TokenTransferAdapter } from "@/strategies/universal-adapters/token-transfer-adapter";
|
|
45
|
+
|
|
46
|
+
// ─── Constants ──────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/** Buffer factor applied to AVNU swap amounts (0.1% less) to account for slippage */
|
|
49
|
+
const AVNU_BUFFER_FACTOR = 0.999;
|
|
50
|
+
|
|
51
|
+
/** Extended crisis max leverage */
|
|
52
|
+
const CRISIS_MAX_LEVERAGE = "4";
|
|
53
|
+
|
|
54
|
+
/** Default acceptable slippage for Extended limit orders (basis points) */
|
|
55
|
+
const DEFAULT_EXTENDED_SLIPPAGE_BPS = 10; // 0.1%
|
|
56
|
+
|
|
57
|
+
/** Default max price divergence between Extended and Vesu execution prices (basis points) */
|
|
58
|
+
const DEFAULT_MAX_PRICE_DIVERGENCE_BPS = 50; // 0.5%
|
|
59
|
+
|
|
60
|
+
/** Max retries for Extended limit order status polling */
|
|
61
|
+
const MAX_ORDER_STATUS_RETRIES = 3;
|
|
62
|
+
|
|
63
|
+
/** Delay between order status poll retries (ms) */
|
|
64
|
+
const ORDER_STATUS_RETRY_DELAY_MS = 5000;
|
|
65
|
+
|
|
66
|
+
/** Default timeout for Extended fill in coordinated dual-exchange mode (ms) */
|
|
67
|
+
const DEFAULT_EXTENDED_FILL_TIMEOUT_MS = 3000;
|
|
68
|
+
|
|
69
|
+
// ─── Config ────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export interface ExecutionConfig {
|
|
72
|
+
networkConfig: IConfig;
|
|
73
|
+
pricer: PricerBase;
|
|
74
|
+
vesuAdapter: VesuMultiplyAdapter;
|
|
75
|
+
vesuModifyPositionAdapter: VesuModifyPositionAdapter;
|
|
76
|
+
extendedAdapter: ExtendedAdapter;
|
|
77
|
+
avnuAdapter: AvnuAdapter;
|
|
78
|
+
usdcToUsdceAdapter: UsdcToUsdceAdapter;
|
|
79
|
+
usdceTransferAdapter: TokenTransferAdapter
|
|
80
|
+
vaultAllocator: ContractAddr;
|
|
81
|
+
walletAddress: string;
|
|
82
|
+
wbtcToken: TokenInfo;
|
|
83
|
+
usdcToken: TokenInfo;
|
|
84
|
+
usdceToken: TokenInfo;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Returns the strategy's merkle tree (built from all leaf adapters).
|
|
88
|
+
* Used to generate proofs for adapter manage calls.
|
|
89
|
+
*/
|
|
90
|
+
getMerkleTree: () => StandardMerkleTree;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Combines merkle proofs and ManageCall[] into a single on-chain Call
|
|
94
|
+
* that invokes `manage_vault_with_merkle_verification` on the manager contract.
|
|
95
|
+
*/
|
|
96
|
+
getManageCall: (proofs: string[][], manageCalls: ManageCall[]) => Call;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Callback to get the bring-liquidity call from the strategy.
|
|
100
|
+
*/
|
|
101
|
+
getBringLiquidityCall: (params: { amount: Web3Number }) => Promise<Call>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Optional callback invoked on key execution lifecycle events.
|
|
105
|
+
* Use to persist state to DB, send alerts, etc.
|
|
106
|
+
*/
|
|
107
|
+
onExecutionEvent?: ExecutionCallback;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Acceptable slippage for Extended limit orders in basis points.
|
|
111
|
+
* E.g. 10 = 0.1%. Default: 10 (0.1%).
|
|
112
|
+
* For BUY orders: limitPrice = midPrice * (1 + slippage)
|
|
113
|
+
* For SELL orders: limitPrice = midPrice * (1 - slippage)
|
|
114
|
+
*/
|
|
115
|
+
extendedAcceptableSlippageBps?: number;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Maximum acceptable price divergence between Extended and Vesu
|
|
119
|
+
* execution prices in basis points. Default: 50 (0.5%).
|
|
120
|
+
*
|
|
121
|
+
* During increase exposure (extended SHORT + vesu LONG):
|
|
122
|
+
* (extendedPrice - vesuPrice) / vesuPrice must be > -maxDivergence
|
|
123
|
+
* During decrease exposure (extended BUY + vesu decrease):
|
|
124
|
+
* (extendedPrice - vesuPrice) / vesuPrice must be < +maxDivergence
|
|
125
|
+
*/
|
|
126
|
+
maxPriceDivergenceBps?: number;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Max time (ms) to wait for Extended limit order fill in coordinated
|
|
130
|
+
* dual-exchange mode. Default: 3000 (3 seconds).
|
|
131
|
+
*/
|
|
132
|
+
extendedFillTimeoutMs?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface ExtendedCancelResolution {
|
|
136
|
+
outcome: "filled" | "cancelled" | "failed";
|
|
137
|
+
order: OpenOrder | null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Execution Service ─────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Processes a {@link SolveResult} and translates each case's {@link ExecutionRoute}s into
|
|
144
|
+
* concrete on-chain transaction calls (batched) or off-chain API operations.
|
|
145
|
+
*
|
|
146
|
+
* Key design principles:
|
|
147
|
+
*
|
|
148
|
+
* 1. **Case-based execution**: Cases are processed sequentially. Within each
|
|
149
|
+
* case, routes execute in priority order.
|
|
150
|
+
*
|
|
151
|
+
* 2. **RETURN_TO_WAIT**: When encountered, all pending on-chain calls are
|
|
152
|
+
* flushed and execution halts. The remaining routes will be re-computed
|
|
153
|
+
* in the next solve cycle once async operations (bridge, deposit credit) settle.
|
|
154
|
+
*
|
|
155
|
+
* 3. **On-chain batching**: Consecutive on-chain routes (Starknet calls) are
|
|
156
|
+
* accumulated into a single multicall batch. Off-chain routes (Extended API)
|
|
157
|
+
* flush the batch, execute independently, then resume accumulation.
|
|
158
|
+
*
|
|
159
|
+
* 4. **pendingDeposit awareness**: Transfers to Extended are reduced by any
|
|
160
|
+
* amount already in transit, avoiding double-sends.
|
|
161
|
+
*
|
|
162
|
+
* 5. **AVNU buffer**: Swap amounts use a 0.1% buffer to account for slippage.
|
|
163
|
+
*
|
|
164
|
+
* Usage:
|
|
165
|
+
* const executor = new ExecutionService(config);
|
|
166
|
+
* const results = await executor.execute(solveResult);
|
|
167
|
+
*/
|
|
168
|
+
export class ExecutionService {
|
|
169
|
+
private readonly _config: ExecutionConfig;
|
|
170
|
+
private readonly _tag = "ExecutionService";
|
|
171
|
+
private readonly _tokenSymbols: Record<string, string>;
|
|
172
|
+
private readonly _tokenDecimals: Record<string, number>;
|
|
173
|
+
private readonly _poolNames: Record<string, string>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Remaining pending deposit budget (consumed during execution to avoid double-sends).
|
|
177
|
+
* Initialised from SolveResult.pendingDeposit at the start of execute().
|
|
178
|
+
*/
|
|
179
|
+
private _pendingDepositRemaining: number = 0;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Starknet account used for on-chain estimation in coordinated mode.
|
|
183
|
+
* Set at the start of execute() and valid for the duration of the call.
|
|
184
|
+
*/
|
|
185
|
+
private _account: Account | null = null;
|
|
186
|
+
|
|
187
|
+
constructor(config: ExecutionConfig) {
|
|
188
|
+
this._config = config;
|
|
189
|
+
const avnuTokens = config.avnuAdapter.config.supportedPositions.map(
|
|
190
|
+
(position) => position.asset,
|
|
191
|
+
);
|
|
192
|
+
this._tokenSymbols = StarknetCallParser.buildTokenSymbolLookup([
|
|
193
|
+
config.wbtcToken,
|
|
194
|
+
config.usdcToken,
|
|
195
|
+
config.usdceToken,
|
|
196
|
+
config.vesuAdapter.config.baseToken,
|
|
197
|
+
config.vesuAdapter.config.collateral,
|
|
198
|
+
config.vesuAdapter.config.debt,
|
|
199
|
+
config.vesuAdapter.config.marginToken,
|
|
200
|
+
config.vesuModifyPositionAdapter.config.collateral,
|
|
201
|
+
config.vesuModifyPositionAdapter.config.debt,
|
|
202
|
+
...avnuTokens,
|
|
203
|
+
]);
|
|
204
|
+
this._tokenDecimals = StarknetCallParser.buildTokenDecimalsLookup([
|
|
205
|
+
config.wbtcToken,
|
|
206
|
+
config.usdcToken,
|
|
207
|
+
config.usdceToken,
|
|
208
|
+
config.vesuAdapter.config.baseToken,
|
|
209
|
+
config.vesuAdapter.config.collateral,
|
|
210
|
+
config.vesuAdapter.config.debt,
|
|
211
|
+
config.vesuAdapter.config.marginToken,
|
|
212
|
+
config.vesuModifyPositionAdapter.config.collateral,
|
|
213
|
+
config.vesuModifyPositionAdapter.config.debt,
|
|
214
|
+
...avnuTokens,
|
|
215
|
+
]);
|
|
216
|
+
this._poolNames = StarknetCallParser.buildPoolNameLookup([
|
|
217
|
+
{
|
|
218
|
+
poolId: config.vesuAdapter.config.poolId.toBigInt(),
|
|
219
|
+
name: `${config.vesuAdapter.config.collateral.symbol}/${config.vesuAdapter.config.debt.symbol}`,
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
poolId: config.vesuModifyPositionAdapter.config.poolId.toBigInt(),
|
|
223
|
+
name: `${config.vesuModifyPositionAdapter.config.collateral.symbol}/${config.vesuModifyPositionAdapter.config.debt.symbol}`,
|
|
224
|
+
},
|
|
225
|
+
]);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
229
|
+
// Internal helpers — manage call building, event emission, limit orders
|
|
230
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Emits a lifecycle event via the configured callback (if present).
|
|
234
|
+
* Safe to call even when no callback is configured.
|
|
235
|
+
*/
|
|
236
|
+
private async _emitEvent(
|
|
237
|
+
eventType: ExecutionEventType,
|
|
238
|
+
metadata: ExecutionEventMetadata,
|
|
239
|
+
): Promise<void> {
|
|
240
|
+
try {
|
|
241
|
+
await this._config.onExecutionEvent?.(eventType, metadata);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
logger.error(`${this._tag}::_emitEvent callback threw: ${err}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private _getProofGroupsForManageCalls(manageCalls: ManageCall[]): string[][] {
|
|
248
|
+
const tree = this._config.getMerkleTree();
|
|
249
|
+
const proofByReadableId = new Map<string, string[]>();
|
|
250
|
+
for (const [i, v] of tree.entries()) {
|
|
251
|
+
if (proofByReadableId.has(v.readableId)) {
|
|
252
|
+
throw new Error(`${this._tag}::_getProofGroupsForManageCalls duplicate readableId: ${v.readableId}`);
|
|
253
|
+
}
|
|
254
|
+
proofByReadableId.set(v.readableId, tree.getProof(i));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return manageCalls.map((manageCall, index) => {
|
|
258
|
+
if (!manageCall.proofReadableId) {
|
|
259
|
+
throw new Error(`${this._tag}::_getProofGroupsForManageCalls missing proofReadableId at index ${index}`);
|
|
260
|
+
}
|
|
261
|
+
const proof = proofByReadableId.get(manageCall.proofReadableId);
|
|
262
|
+
if (!proof) {
|
|
263
|
+
throw new Error(`${this._tag}::_getProofGroupsForManageCalls proof not found for readableId=${manageCall.proofReadableId}`);
|
|
264
|
+
}
|
|
265
|
+
return proof;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Builds a single manage call (manage_vault_with_merkle_verification) for an adapter.
|
|
271
|
+
*
|
|
272
|
+
* Pattern:
|
|
273
|
+
* 1. Calls adapter.getDepositCall / getWithdrawCall to get ManageCall[]
|
|
274
|
+
* 2. Gets merkle proofs for the adapter's leaves
|
|
275
|
+
* 3. Combines via getManageCall into a single on-chain Call
|
|
276
|
+
*
|
|
277
|
+
* Use this for any VA-routed operation that needs merkle proof verification.
|
|
278
|
+
*/
|
|
279
|
+
private async _buildAdapterManageCall<T extends { amount: Web3Number } = { amount: Web3Number }>(
|
|
280
|
+
adapter: {
|
|
281
|
+
getDepositCall: (params: T) => Promise<ManageCall[]>;
|
|
282
|
+
getWithdrawCall: (params: T) => Promise<ManageCall[]>;
|
|
283
|
+
},
|
|
284
|
+
isDeposit: boolean,
|
|
285
|
+
params: T,
|
|
286
|
+
): Promise<Call> {
|
|
287
|
+
const manageCalls = isDeposit
|
|
288
|
+
? await adapter.getDepositCall(params)
|
|
289
|
+
: await adapter.getWithdrawCall(params);
|
|
290
|
+
if (manageCalls.length === 0) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`${this._tag}::_buildAdapterManageCall adapter returned empty ManageCall[] ` +
|
|
293
|
+
`(isDeposit=${isDeposit}, amount=${params.amount.toNumber()})`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
StarknetCallParser.logManageCallsSummary(
|
|
297
|
+
`${this._tag}::_buildAdapterManageCall decoded manageCalls`,
|
|
298
|
+
manageCalls,
|
|
299
|
+
{
|
|
300
|
+
tokenSymbols: this._tokenSymbols,
|
|
301
|
+
tokenDecimals: this._tokenDecimals,
|
|
302
|
+
poolNames: this._poolNames,
|
|
303
|
+
},
|
|
304
|
+
(message) => logger.debug(message),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
return this._config.getManageCall(
|
|
308
|
+
this._getProofGroupsForManageCalls(manageCalls),
|
|
309
|
+
manageCalls,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async _getExtendedMidPrice(): Promise<number | null> {
|
|
314
|
+
const { bid, ask } = await this._config.extendedAdapter.fetchOrderBookFromExtended();
|
|
315
|
+
if (
|
|
316
|
+
!bid ||
|
|
317
|
+
!ask ||
|
|
318
|
+
bid.lessThanOrEqualTo(0) ||
|
|
319
|
+
ask.lessThanOrEqualTo(0)
|
|
320
|
+
) {
|
|
321
|
+
logger.error(
|
|
322
|
+
`${this._tag}::_executeExtendedLimitOrder invalid orderbook: ` +
|
|
323
|
+
`bid=${bid?.toNumber()}, ask=${ask?.toNumber()}`,
|
|
324
|
+
);
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const midPrice = ask.plus(bid).div(2);
|
|
329
|
+
return midPrice.toNumber();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Executes a limit order on Extended with acceptable slippage from mid spot price.
|
|
334
|
+
*
|
|
335
|
+
* Flow:
|
|
336
|
+
* 1. Set leverage on the market
|
|
337
|
+
* 2. Fetch orderbook → compute mid price
|
|
338
|
+
* 3. Apply configured slippage to derive limit price
|
|
339
|
+
* 4. Place IOC limit order at that price
|
|
340
|
+
* 5. Poll for fill status with retries
|
|
341
|
+
*
|
|
342
|
+
* Returns execution details including the fill price, or null on failure.
|
|
343
|
+
*/
|
|
344
|
+
private async _executeExtendedLimitOrder(
|
|
345
|
+
btcAmount: number,
|
|
346
|
+
idealPrice: number,
|
|
347
|
+
side: OrderSide,
|
|
348
|
+
maxAttempts: number = 1,
|
|
349
|
+
options?: {
|
|
350
|
+
onOrderCreated?: (orderId: string) => void;
|
|
351
|
+
},
|
|
352
|
+
): Promise<{
|
|
353
|
+
position_id: string;
|
|
354
|
+
btc_exposure: string;
|
|
355
|
+
executionPrice: number;
|
|
356
|
+
} | null> {
|
|
357
|
+
const adapter = this._config.extendedAdapter;
|
|
358
|
+
const marketName = adapter.config.extendedMarketName;
|
|
359
|
+
logger.info(`${this._tag}::_executeExtendedLimitOrder idealPrice=${idealPrice} side=${side} btcAmount=${btcAmount} maxAttempts=${maxAttempts}`);
|
|
360
|
+
|
|
361
|
+
// const setLevResult = await adapter.setLeverage(leverage, marketName);
|
|
362
|
+
// if (!setLevResult) {
|
|
363
|
+
// logger.error(
|
|
364
|
+
// `${this._tag}::_executeExtendedLimitOrder failed to set leverage`,
|
|
365
|
+
// );
|
|
366
|
+
// return null;
|
|
367
|
+
// }
|
|
368
|
+
|
|
369
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
370
|
+
// const { bid, ask } = await adapter.fetchOrderBookFromExtended();
|
|
371
|
+
// if (
|
|
372
|
+
// !bid ||
|
|
373
|
+
// !ask ||
|
|
374
|
+
// bid.lessThanOrEqualTo(0) ||
|
|
375
|
+
// ask.lessThanOrEqualTo(0)
|
|
376
|
+
// ) {
|
|
377
|
+
// logger.error(
|
|
378
|
+
// `${this._tag}::_executeExtendedLimitOrder invalid orderbook: ` +
|
|
379
|
+
// `bid=${bid?.toNumber()}, ask=${ask?.toNumber()}`,
|
|
380
|
+
// );
|
|
381
|
+
// return null;
|
|
382
|
+
// }
|
|
383
|
+
|
|
384
|
+
// const midPrice = ask.plus(bid).div(2);
|
|
385
|
+
// const slippageBps =
|
|
386
|
+
// this._config.extendedAcceptableSlippageBps ??
|
|
387
|
+
// DEFAULT_EXTENDED_SLIPPAGE_BPS;
|
|
388
|
+
// const slippageFactor = slippageBps / 10000;
|
|
389
|
+
|
|
390
|
+
const limitPrice = Number((
|
|
391
|
+
side === OrderSide.BUY
|
|
392
|
+
? idealPrice * (1 + 0.005)
|
|
393
|
+
: idealPrice * (1 - 0.005)).toFixed(2));
|
|
394
|
+
|
|
395
|
+
const amountInToken = (btcAmount).toFixed(
|
|
396
|
+
adapter.config.extendedPrecision,
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
logger.info(
|
|
400
|
+
`${this._tag}::_executeExtendedLimitOrder idealPrice=${idealPrice} ` +
|
|
401
|
+
`side=${side} amount=${amountInToken} ` +
|
|
402
|
+
`limitPrice=${limitPrice.toFixed(0)}`,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
await this._emitEvent(ExecutionEventType.INITIATED, {
|
|
406
|
+
routeType: `EXTENDED_LIMIT_${side}`,
|
|
407
|
+
protocol: 'EXTENDED',
|
|
408
|
+
amount: amountInToken,
|
|
409
|
+
limitPrice: limitPrice,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const result = await adapter.createExtendedPositon(
|
|
413
|
+
adapter.client,
|
|
414
|
+
marketName,
|
|
415
|
+
amountInToken,
|
|
416
|
+
limitPrice.toFixed(0),
|
|
417
|
+
side,
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
if (!result || !result.position_id) {
|
|
421
|
+
logger.error(
|
|
422
|
+
`${this._tag}::_executeExtendedLimitOrder order creation failed (attempt ${attempt})`,
|
|
423
|
+
);
|
|
424
|
+
if (attempt < maxAttempts) {
|
|
425
|
+
await new Promise((r) => setTimeout(r, 2000 * attempt));
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
await this._emitEvent(ExecutionEventType.FAILURE, {
|
|
429
|
+
routeType: `EXTENDED_LIMIT_${side}`,
|
|
430
|
+
error: 'Order creation failed after max attempts',
|
|
431
|
+
});
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
options?.onOrderCreated?.(result.position_id);
|
|
435
|
+
|
|
436
|
+
// Poll order status
|
|
437
|
+
let openOrder = await adapter.getOrderStatus(
|
|
438
|
+
result.position_id,
|
|
439
|
+
marketName,
|
|
440
|
+
);
|
|
441
|
+
if (!openOrder) {
|
|
442
|
+
for (let sr = 1; sr <= MAX_ORDER_STATUS_RETRIES; sr++) {
|
|
443
|
+
await new Promise((r) => setTimeout(r, ORDER_STATUS_RETRY_DELAY_MS));
|
|
444
|
+
openOrder = await adapter.getOrderStatus(
|
|
445
|
+
result.position_id,
|
|
446
|
+
marketName,
|
|
447
|
+
);
|
|
448
|
+
if (openOrder) break;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (openOrder && openOrder.status === OrderStatus.FILLED) {
|
|
453
|
+
const executionPrice = parseFloat(
|
|
454
|
+
openOrder.average_price || openOrder.price || '0',
|
|
455
|
+
);
|
|
456
|
+
logger.info(
|
|
457
|
+
`${this._tag}::_executeExtendedLimitOrder FILLED orderId=${result.position_id} ` +
|
|
458
|
+
`qty=${openOrder.qty} execPrice=${executionPrice}`,
|
|
459
|
+
);
|
|
460
|
+
await this._emitEvent(ExecutionEventType.SUCCESS, {
|
|
461
|
+
routeType: `EXTENDED_LIMIT_${side}`,
|
|
462
|
+
orderId: result.position_id,
|
|
463
|
+
amount: openOrder.qty,
|
|
464
|
+
executionPrice,
|
|
465
|
+
limitPrice: limitPrice,
|
|
466
|
+
});
|
|
467
|
+
return {
|
|
468
|
+
position_id: result.position_id,
|
|
469
|
+
btc_exposure: openOrder.qty,
|
|
470
|
+
executionPrice,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (openOrder && openOrder.status !== OrderStatus.FILLED) {
|
|
475
|
+
logger.warn(
|
|
476
|
+
`${this._tag}::_executeExtendedLimitOrder order ${result.position_id} ` +
|
|
477
|
+
`status=${openOrder.status}, not FILLED — retrying`,
|
|
478
|
+
);
|
|
479
|
+
if (attempt < maxAttempts) {
|
|
480
|
+
await new Promise((r) => setTimeout(r, 2000 * attempt));
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Order exists but status unknown after retries (API delay)
|
|
486
|
+
if (!openOrder) {
|
|
487
|
+
logger.warn(
|
|
488
|
+
`${this._tag}::_executeExtendedLimitOrder order ${result.position_id} ` +
|
|
489
|
+
`status unknown after ${MAX_ORDER_STATUS_RETRIES} retries — returning position_id`,
|
|
490
|
+
);
|
|
491
|
+
return {
|
|
492
|
+
position_id: result.position_id,
|
|
493
|
+
btc_exposure: amountInToken,
|
|
494
|
+
executionPrice: limitPrice,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
await this._emitEvent(ExecutionEventType.FAILURE, {
|
|
500
|
+
routeType: `EXTENDED_LIMIT_${side}`,
|
|
501
|
+
error: `Max attempts (${maxAttempts}) reached without fill`,
|
|
502
|
+
});
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private async _fetchExtendedOrderStatusWithRetries(
|
|
507
|
+
orderId: string,
|
|
508
|
+
): Promise<OpenOrder | null> {
|
|
509
|
+
const adapter = this._config.extendedAdapter;
|
|
510
|
+
const marketName = adapter.config.extendedMarketName;
|
|
511
|
+
|
|
512
|
+
let openOrder = await adapter.getOrderStatus(orderId, marketName);
|
|
513
|
+
if (openOrder) return openOrder;
|
|
514
|
+
|
|
515
|
+
for (let sr = 1; sr <= MAX_ORDER_STATUS_RETRIES; sr++) {
|
|
516
|
+
await new Promise((r) => setTimeout(r, ORDER_STATUS_RETRY_DELAY_MS));
|
|
517
|
+
openOrder = await adapter.getOrderStatus(orderId, marketName);
|
|
518
|
+
logger.debug(
|
|
519
|
+
`${this._tag}::_fetchExtendedOrderStatusWithRetries order=${orderId} status=${openOrder?.status} retry=${sr}`,
|
|
520
|
+
);
|
|
521
|
+
if (openOrder) return openOrder;
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private async _cancelExtendedOrderIfOpen(
|
|
527
|
+
orderId: string,
|
|
528
|
+
knownOrder?: OpenOrder | null,
|
|
529
|
+
): Promise<ExtendedCancelResolution> {
|
|
530
|
+
const adapter = this._config.extendedAdapter;
|
|
531
|
+
const marketName = adapter.config.extendedMarketName;
|
|
532
|
+
const latestOrder = knownOrder ?? await this._fetchExtendedOrderStatusWithRetries(orderId);
|
|
533
|
+
|
|
534
|
+
if (latestOrder?.status === OrderStatus.FILLED) {
|
|
535
|
+
logger.info(
|
|
536
|
+
`${this._tag}::_cancelExtendedOrderIfOpen order ${orderId} already FILLED — skip cancel`,
|
|
537
|
+
);
|
|
538
|
+
return { outcome: "filled", order: latestOrder };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (
|
|
542
|
+
latestOrder &&
|
|
543
|
+
(
|
|
544
|
+
latestOrder.status === OrderStatus.CANCELLED ||
|
|
545
|
+
latestOrder.status === OrderStatus.EXPIRED ||
|
|
546
|
+
latestOrder.status === OrderStatus.REJECTED
|
|
547
|
+
)
|
|
548
|
+
) {
|
|
549
|
+
logger.info(
|
|
550
|
+
`${this._tag}::_cancelExtendedOrderIfOpen order ${orderId} already ${latestOrder.status}`,
|
|
551
|
+
);
|
|
552
|
+
return { outcome: "cancelled", order: latestOrder };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const numericOrderId = Number(orderId);
|
|
556
|
+
if (!Number.isFinite(numericOrderId)) {
|
|
557
|
+
logger.warn(
|
|
558
|
+
`${this._tag}::_cancelExtendedOrderIfOpen invalid orderId="${orderId}" — cannot cancel`,
|
|
559
|
+
);
|
|
560
|
+
return { outcome: "failed", order: latestOrder ?? null };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
const cancelResult = await adapter.client.cancelOrderById(numericOrderId);
|
|
565
|
+
if (cancelResult.status !== "OK") {
|
|
566
|
+
logger.error(
|
|
567
|
+
`${this._tag}::_cancelExtendedOrderIfOpen cancel failed for order ${orderId}: ${cancelResult.message}`,
|
|
568
|
+
);
|
|
569
|
+
return { outcome: "failed", order: latestOrder ?? null };
|
|
570
|
+
}
|
|
571
|
+
} catch (err) {
|
|
572
|
+
logger.error(
|
|
573
|
+
`${this._tag}::_cancelExtendedOrderIfOpen cancel threw for order ${orderId}: ${err}`,
|
|
574
|
+
);
|
|
575
|
+
return { outcome: "failed", order: latestOrder ?? null };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const afterCancel = await this._fetchExtendedOrderStatusWithRetries(orderId);
|
|
579
|
+
if (!afterCancel) {
|
|
580
|
+
logger.warn(
|
|
581
|
+
`${this._tag}::_cancelExtendedOrderIfOpen no status after cancel for order ${orderId} on ${marketName}`,
|
|
582
|
+
);
|
|
583
|
+
return { outcome: "cancelled", order: null };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (afterCancel.status === OrderStatus.FILLED) {
|
|
587
|
+
logger.warn(
|
|
588
|
+
`${this._tag}::_cancelExtendedOrderIfOpen order ${orderId} became FILLED during cancel`,
|
|
589
|
+
);
|
|
590
|
+
return { outcome: "filled", order: afterCancel };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (afterCancel.status === OrderStatus.CANCELLED) {
|
|
594
|
+
logger.info(
|
|
595
|
+
`${this._tag}::_cancelExtendedOrderIfOpen cancelled order ${orderId} successfully`,
|
|
596
|
+
);
|
|
597
|
+
return { outcome: "cancelled", order: afterCancel };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
logger.warn(
|
|
601
|
+
`${this._tag}::_cancelExtendedOrderIfOpen order ${orderId} status after cancel=${afterCancel.status}`,
|
|
602
|
+
);
|
|
603
|
+
return { outcome: "failed", order: afterCancel };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private async _executeExtendedLimitOrderWithRecovery(
|
|
607
|
+
btcAmount: number,
|
|
608
|
+
idealPrice: number,
|
|
609
|
+
side: OrderSide,
|
|
610
|
+
options?: {
|
|
611
|
+
maxAttempts?: number;
|
|
612
|
+
timeoutMs?: number;
|
|
613
|
+
contextTag?: string;
|
|
614
|
+
},
|
|
615
|
+
): Promise<{
|
|
616
|
+
position_id: string;
|
|
617
|
+
btc_exposure: string;
|
|
618
|
+
executionPrice: number;
|
|
619
|
+
} | null> {
|
|
620
|
+
const maxAttempts = options?.maxAttempts ?? 1;
|
|
621
|
+
const contextTag = options?.contextTag ?? "_executeExtendedLimitOrderWithRecovery";
|
|
622
|
+
let createdOrderId: string | null = null;
|
|
623
|
+
|
|
624
|
+
const fillPromise = this._executeExtendedLimitOrder(
|
|
625
|
+
btcAmount,
|
|
626
|
+
idealPrice,
|
|
627
|
+
side,
|
|
628
|
+
maxAttempts,
|
|
629
|
+
{
|
|
630
|
+
onOrderCreated: (orderId) => {
|
|
631
|
+
createdOrderId = orderId;
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
let extResult: {
|
|
637
|
+
position_id: string;
|
|
638
|
+
btc_exposure: string;
|
|
639
|
+
executionPrice: number;
|
|
640
|
+
} | null = null;
|
|
641
|
+
|
|
642
|
+
if (options?.timeoutMs && options.timeoutMs > 0) {
|
|
643
|
+
const timeoutPromise = new Promise<null>((resolve) =>
|
|
644
|
+
setTimeout(() => resolve(null), options.timeoutMs),
|
|
645
|
+
);
|
|
646
|
+
logger.info(
|
|
647
|
+
`${this._tag}::${contextTag} racing Extended fill against ${options.timeoutMs}ms timeout`,
|
|
648
|
+
);
|
|
649
|
+
extResult = await Promise.race([fillPromise, timeoutPromise]);
|
|
650
|
+
} else {
|
|
651
|
+
extResult = await fillPromise;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (extResult) {
|
|
655
|
+
return extResult;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (!createdOrderId) {
|
|
659
|
+
logger.error(
|
|
660
|
+
`${this._tag}::${contextTag} no order created after fill attempt`,
|
|
661
|
+
);
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
logger.info(
|
|
666
|
+
`${this._tag}::${contextTag} fetching order status for order ${createdOrderId}`,
|
|
667
|
+
);
|
|
668
|
+
const knownOrder = await this._fetchExtendedOrderStatusWithRetries(createdOrderId);
|
|
669
|
+
if (knownOrder?.status === OrderStatus.FILLED) {
|
|
670
|
+
const executionPrice = parseFloat(
|
|
671
|
+
knownOrder.average_price || knownOrder.price || '0',
|
|
672
|
+
);
|
|
673
|
+
logger.info(
|
|
674
|
+
`${this._tag}::${contextTag} order ${createdOrderId} confirmed FILLED after assumed failure`,
|
|
675
|
+
);
|
|
676
|
+
return {
|
|
677
|
+
position_id: createdOrderId,
|
|
678
|
+
btc_exposure: knownOrder.qty,
|
|
679
|
+
executionPrice,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const cancelResolution = await this._cancelExtendedOrderIfOpen(
|
|
684
|
+
createdOrderId,
|
|
685
|
+
knownOrder,
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
if (cancelResolution.outcome === "filled") {
|
|
689
|
+
const filledOrder = cancelResolution.order;
|
|
690
|
+
const executionPrice = parseFloat(
|
|
691
|
+
filledOrder?.average_price || filledOrder?.price || '0',
|
|
692
|
+
);
|
|
693
|
+
logger.info(
|
|
694
|
+
`${this._tag}::${contextTag} order ${createdOrderId} filled during cancel path`,
|
|
695
|
+
);
|
|
696
|
+
return {
|
|
697
|
+
position_id: createdOrderId,
|
|
698
|
+
btc_exposure: filledOrder?.qty || btcAmount.toString(),
|
|
699
|
+
executionPrice,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (cancelResolution.outcome === "failed") {
|
|
704
|
+
logger.warn(
|
|
705
|
+
`${this._tag}::${contextTag} failed to confirm cancel for order ${createdOrderId}`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** ExecutionRoute types that change BTC exposure (on-chain or off-chain). */
|
|
712
|
+
private static readonly EXPOSURE_CHANGING_ROUTES = new Set([
|
|
713
|
+
RouteType.AVNU_DEPOSIT_SWAP,
|
|
714
|
+
RouteType.AVNU_WITHDRAW_SWAP,
|
|
715
|
+
RouteType.VESU_MULTIPLY_INCREASE_LEVER,
|
|
716
|
+
RouteType.VESU_MULTIPLY_DECREASE_LEVER,
|
|
717
|
+
RouteType.EXTENDED_INCREASE_LEVER,
|
|
718
|
+
RouteType.EXTENDED_DECREASE_LEVER,
|
|
719
|
+
]);
|
|
720
|
+
|
|
721
|
+
/** ExecutionRoute types that correspond to increasing delta-neutral exposure. */
|
|
722
|
+
private static readonly INCREASE_EXPOSURE_ROUTES = new Set([
|
|
723
|
+
RouteType.AVNU_DEPOSIT_SWAP,
|
|
724
|
+
RouteType.VESU_MULTIPLY_INCREASE_LEVER,
|
|
725
|
+
RouteType.EXTENDED_INCREASE_LEVER,
|
|
726
|
+
]);
|
|
727
|
+
|
|
728
|
+
/** On-chain route types that change BTC exposure (Vesu side). */
|
|
729
|
+
private static readonly ON_CHAIN_EXPOSURE_ROUTES = new Set([
|
|
730
|
+
RouteType.AVNU_DEPOSIT_SWAP,
|
|
731
|
+
RouteType.AVNU_WITHDRAW_SWAP,
|
|
732
|
+
RouteType.VESU_MULTIPLY_INCREASE_LEVER,
|
|
733
|
+
RouteType.VESU_MULTIPLY_DECREASE_LEVER,
|
|
734
|
+
]);
|
|
735
|
+
|
|
736
|
+
/** Off-chain Extended route types that change BTC exposure. */
|
|
737
|
+
private static readonly EXTENDED_EXPOSURE_ROUTES = new Set([
|
|
738
|
+
RouteType.EXTENDED_INCREASE_LEVER,
|
|
739
|
+
RouteType.EXTENDED_DECREASE_LEVER,
|
|
740
|
+
]);
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Validates that the price divergence between Extended (orderbook mid) and
|
|
744
|
+
* AVNU (actual on-chain swap price) is within acceptable limits.
|
|
745
|
+
*
|
|
746
|
+
* Mirrors the check from `checkPriceDifferenceBetweenAvnuAndExtended` in the
|
|
747
|
+
* strategy — see investmentOrchestrator.ts for the call-site pattern.
|
|
748
|
+
*
|
|
749
|
+
* For OPEN (increasing exposure): we SELL on Extended (short) and BUY on AVNU (long).
|
|
750
|
+
* → Extended price should not be too far below AVNU price.
|
|
751
|
+
* → (extendedMid − avnuPrice) / avnuPrice must be > −maxDivergence
|
|
752
|
+
*
|
|
753
|
+
* For CLOSE (decreasing exposure): we BUY on Extended (close short) and SELL on AVNU.
|
|
754
|
+
* → Extended price should not be too far above AVNU price.
|
|
755
|
+
* → (extendedMid − avnuPrice) / avnuPrice must be < +maxDivergence
|
|
756
|
+
*
|
|
757
|
+
* @param isIncreasingExposure true when opening/increasing delta-neutral position
|
|
758
|
+
* @throws if divergence exceeds configured maxPriceDivergenceBps
|
|
759
|
+
*/
|
|
760
|
+
private async _validatePriceDivergence(
|
|
761
|
+
isIncreasingExposure: boolean,
|
|
762
|
+
executionPrice: number,
|
|
763
|
+
): Promise<void> {
|
|
764
|
+
const { extendedAdapter } = this._config;
|
|
765
|
+
|
|
766
|
+
// 1. Extended mid price from orderbook
|
|
767
|
+
const { bid, ask } = await extendedAdapter.fetchOrderBookFromExtended();
|
|
768
|
+
if (bid.lessThanOrEqualTo(0) || ask.lessThanOrEqualTo(0)) {
|
|
769
|
+
logger.warn(
|
|
770
|
+
`${this._tag}::_validatePriceDivergence could not fetch valid orderbook — skipping check`,
|
|
771
|
+
);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const extendedMidPrice = ask.plus(bid).div(2);
|
|
775
|
+
|
|
776
|
+
// 2. Compute divergence against actual execution price from adapter quotes
|
|
777
|
+
const priceDiff = extendedMidPrice.minus(executionPrice.toFixed(6)).toNumber();
|
|
778
|
+
const priceDiffPct = priceDiff / executionPrice;
|
|
779
|
+
const maxBps =
|
|
780
|
+
this._config.maxPriceDivergenceBps ?? DEFAULT_MAX_PRICE_DIVERGENCE_BPS;
|
|
781
|
+
const maxPctAllowed = maxBps / 10000;
|
|
782
|
+
|
|
783
|
+
// todo price is more subject to direction.
|
|
784
|
+
logger.info(
|
|
785
|
+
`${this._tag}::_validatePriceDivergence extendedMid=${extendedMidPrice.toNumber()} ` +
|
|
786
|
+
`vesuExecutionPrice=${executionPrice} diff=${priceDiff.toFixed(2)} ` +
|
|
787
|
+
`pct=${(priceDiffPct * 100).toFixed(3)}% maxAllowed=±${(maxPctAllowed * 100).toFixed(1)}% ` +
|
|
788
|
+
`isIncrease=${isIncreasingExposure}`,
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
if (isIncreasingExposure && priceDiffPct < -maxPctAllowed) {
|
|
792
|
+
throw new Error(
|
|
793
|
+
`Price divergence too large for OPEN: extendedMid − executionPrice = ${priceDiff.toFixed(2)} ` +
|
|
794
|
+
`(${(priceDiffPct * 100).toFixed(3)}%, limit: -${(maxPctAllowed * 100).toFixed(1)}%). ` +
|
|
795
|
+
`Extended=${extendedMidPrice.toNumber()}, executionPrice=${executionPrice}`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
if (!isIncreasingExposure && priceDiffPct > maxPctAllowed) {
|
|
799
|
+
throw new Error(
|
|
800
|
+
`Price divergence too large for CLOSE: extendedMid − executionPrice = ${priceDiff.toFixed(2)} ` +
|
|
801
|
+
`(${(priceDiffPct * 100).toFixed(3)}%, limit: +${(maxPctAllowed * 100).toFixed(1)}%). ` +
|
|
802
|
+
`Extended=${extendedMidPrice.toNumber()}, executionPrice=${executionPrice}`,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Computes the net weighted execution price (USDC per BTC) across all adapters
|
|
809
|
+
* that have a stored SwapPriceInfo from the most recent call build.
|
|
810
|
+
*
|
|
811
|
+
* For deposit (USDC→BTC): price = sum(USDC sold) / sum(BTC bought)
|
|
812
|
+
* For withdraw (BTC→USDC): price = sum(USDC bought) / sum(BTC sold)
|
|
813
|
+
*/
|
|
814
|
+
private _getNetExecutionPrice(isDeposit: boolean): number | null {
|
|
815
|
+
const prices: SwapPriceInfo[] = [
|
|
816
|
+
this._config.avnuAdapter.lastSwapPriceInfo,
|
|
817
|
+
this._config.vesuAdapter.lastSwapPriceInfo,
|
|
818
|
+
].filter((p): p is SwapPriceInfo => p !== null);
|
|
819
|
+
|
|
820
|
+
if (prices.length === 0) return null;
|
|
821
|
+
|
|
822
|
+
if (isDeposit) {
|
|
823
|
+
const totalUsdc = prices.reduce((s, p) => s + p.fromAmount, 0);
|
|
824
|
+
const totalBtc = prices.reduce((s, p) => s + p.toAmount, 0);
|
|
825
|
+
return totalBtc !== 0 ? totalUsdc / totalBtc : null;
|
|
826
|
+
} else {
|
|
827
|
+
const totalUsdc = prices.reduce((s, p) => s + p.toAmount, 0);
|
|
828
|
+
const totalBtc = prices.reduce((s, p) => s + p.fromAmount, 0);
|
|
829
|
+
return totalBtc !== 0 ? totalUsdc / totalBtc : null;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Clears cached swap price info on all adapters to prevent stale data across cycles. */
|
|
834
|
+
private _clearAdapterPriceInfo(): void {
|
|
835
|
+
this._config.avnuAdapter.lastSwapPriceInfo = null;
|
|
836
|
+
this._config.vesuAdapter.lastSwapPriceInfo = null;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
840
|
+
// Public API
|
|
841
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Main entry point: takes a SolveResult and executes all cases in order,
|
|
845
|
+
* building the necessary transaction calls.
|
|
846
|
+
*
|
|
847
|
+
* - Cases are processed sequentially.
|
|
848
|
+
* - Within a case, on-chain calls are batched; off-chain calls flush the batch.
|
|
849
|
+
* - Dual-exchange exposure cases use the coordinated path (construct → estimate → fill Extended → send on-chain).
|
|
850
|
+
* - RETURN_TO_WAIT halts execution for the current case and all subsequent cases.
|
|
851
|
+
* - Execution stops on the first route failure.
|
|
852
|
+
*
|
|
853
|
+
* @param account Starknet account used for on-chain fee estimation in coordinated mode.
|
|
854
|
+
* @returns Ordered list of TransactionResults (one per batch/off-chain op).
|
|
855
|
+
*/
|
|
856
|
+
async execute(solveResult: SolveResult, account: Account): Promise<TransactionResult[]> {
|
|
857
|
+
this._account = account;
|
|
858
|
+
this._pendingDepositRemaining = solveResult.pendingDeposit?.toNumber() ?? 0;
|
|
859
|
+
const results: TransactionResult[] = [];
|
|
860
|
+
|
|
861
|
+
if (solveResult.cases.length > 0) {
|
|
862
|
+
logger.info(
|
|
863
|
+
`${this._tag}::execute detected ${solveResult.cases.length} case(s): ` +
|
|
864
|
+
solveResult.cases.map((c) => `[${c.case.category}] ${c.case.id}`).join(', '),
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
for (const caseEntry of solveResult.cases) {
|
|
869
|
+
logger.info(
|
|
870
|
+
`${this._tag}::execute processing case "${caseEntry.case.id}" ` +
|
|
871
|
+
`(${caseEntry.routes.length} routes)`,
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
const caseResults = await this._executeCase(caseEntry);
|
|
875
|
+
results.push(...caseResults);
|
|
876
|
+
|
|
877
|
+
if (caseResults.some((r) => !r.status)) {
|
|
878
|
+
logger.error(
|
|
879
|
+
`${this._tag}::execute case "${caseEntry.case.id}" failed — stopping`,
|
|
880
|
+
);
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (this._caseHitReturnToWait(caseEntry.routes)) {
|
|
885
|
+
logger.info(
|
|
886
|
+
`${this._tag}::execute case "${caseEntry.case.id}" hit RETURN_TO_WAIT — stopping all execution`,
|
|
887
|
+
);
|
|
888
|
+
break;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const failedCount = results.filter((r) => !r.status).length;
|
|
893
|
+
logger.info(
|
|
894
|
+
`${this._tag}::execute completed cases=${solveResult.cases.length} results=${results.length} failed=${failedCount}`,
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
return results;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
901
|
+
// Private — case-level execution
|
|
902
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Executes a single case's routes.
|
|
906
|
+
*
|
|
907
|
+
* First splits routes at the first RETURN_TO_WAIT into an "active window"
|
|
908
|
+
* (routes that will execute this cycle). If the active window contains both
|
|
909
|
+
* on-chain and off-chain exposure routes, delegates to the coordinated
|
|
910
|
+
* dual-exchange path. Otherwise, uses the standard route-by-route flow.
|
|
911
|
+
*/
|
|
912
|
+
private async _executeCase(
|
|
913
|
+
caseEntry: SolveCaseEntry,
|
|
914
|
+
): Promise<TransactionResult[]> {
|
|
915
|
+
const routes = [...caseEntry.routes].sort((a, b) => a.priority - b.priority);
|
|
916
|
+
|
|
917
|
+
// ── Split at first RETURN_TO_WAIT ─────────────────────────────────
|
|
918
|
+
const waitIdx = routes.findIndex((r) => r.type === RouteType.RETURN_TO_WAIT);
|
|
919
|
+
const activeRoutes = waitIdx >= 0 ? routes.slice(0, waitIdx) : routes;
|
|
920
|
+
const hasReturnToWait = waitIdx >= 0;
|
|
921
|
+
|
|
922
|
+
// ── Branch: coordinated dual-exchange path ────────────────────────
|
|
923
|
+
if (this._isDualExchangeCase(activeRoutes)) {
|
|
924
|
+
logger.info(
|
|
925
|
+
`${this._tag}::_executeCase case "${caseEntry.case.id}" → coordinated dual-exchange path` +
|
|
926
|
+
(hasReturnToWait ? ' (RETURN_TO_WAIT present, will halt after)' : ''),
|
|
927
|
+
);
|
|
928
|
+
return this._executeCoordinatedCase(caseEntry, activeRoutes);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ── Standard route-by-route flow (unchanged) ─────────────────────
|
|
932
|
+
return this._executeStandardCase(caseEntry, routes);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Standard route-by-route execution for non-coordinated cases.
|
|
937
|
+
*
|
|
938
|
+
* On-chain routes are accumulated into a Call[] batch. When an off-chain
|
|
939
|
+
* route or RETURN_TO_WAIT is encountered, the batch is flushed first.
|
|
940
|
+
*/
|
|
941
|
+
private async _executeStandardCase(
|
|
942
|
+
caseEntry: SolveCaseEntry,
|
|
943
|
+
routes: ExecutionRoute[],
|
|
944
|
+
): Promise<TransactionResult[]> {
|
|
945
|
+
const results: TransactionResult[] = [];
|
|
946
|
+
this._clearAdapterPriceInfo();
|
|
947
|
+
|
|
948
|
+
try {
|
|
949
|
+
// ── Phase 1: Pre-build all on-chain calls (triggers quote fetches) ──
|
|
950
|
+
const prebuiltCalls = new Map<number, Call[]>();
|
|
951
|
+
for (let i = 0; i < routes.length; i++) {
|
|
952
|
+
const route = routes[i];
|
|
953
|
+
if (route.type === RouteType.RETURN_TO_WAIT) break;
|
|
954
|
+
if (this._isOnChainRoute(route)) {
|
|
955
|
+
try {
|
|
956
|
+
prebuiltCalls.set(i, await this._buildOnChainCalls(route));
|
|
957
|
+
} catch (err) {
|
|
958
|
+
logger.error(
|
|
959
|
+
`${this._tag}::_executeStandardCase on-chain build failed for ${route.type}: ${err}`,
|
|
960
|
+
);
|
|
961
|
+
results.push(this._failureResult(route));
|
|
962
|
+
return results;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
// ── Phase 2: Execute routes using pre-built calls ──
|
|
970
|
+
let batchCalls: Call[] = [];
|
|
971
|
+
|
|
972
|
+
for (let i = 0; i < routes.length; i++) {
|
|
973
|
+
const route = routes[i];
|
|
974
|
+
logger.info(
|
|
975
|
+
`${this._tag}::_executeStandardCase route ${route.type} ${routeSummary(route)}`,
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
if (route.type === RouteType.RETURN_TO_WAIT) {
|
|
979
|
+
if (batchCalls.length > 0) {
|
|
980
|
+
results.push(this._onChainBatchResult(batchCalls));
|
|
981
|
+
batchCalls = [];
|
|
982
|
+
}
|
|
983
|
+
logger.info(
|
|
984
|
+
`${this._tag}::_executeStandardCase RETURN_TO_WAIT — halting case "${caseEntry.case.id}"`,
|
|
985
|
+
);
|
|
986
|
+
break;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (this._isOnChainRoute(route)) {
|
|
990
|
+
const calls = prebuiltCalls.get(i);
|
|
991
|
+
if (calls) batchCalls.push(...calls);
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Off-chain route → flush batch, then execute
|
|
996
|
+
if (batchCalls.length > 0) {
|
|
997
|
+
const batchResult = this._onChainBatchResult(batchCalls);
|
|
998
|
+
results.push(batchResult);
|
|
999
|
+
await this._emitEvent(ExecutionEventType.INITIATED, {
|
|
1000
|
+
routeSummary: `on-chain batch (${batchCalls.length} calls)`,
|
|
1001
|
+
calls: batchCalls,
|
|
1002
|
+
});
|
|
1003
|
+
batchCalls = [];
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
try {
|
|
1007
|
+
const result = await this._executeOffChainRoute(route);
|
|
1008
|
+
if (result) {
|
|
1009
|
+
results.push(result);
|
|
1010
|
+
if (!result.status) return results;
|
|
1011
|
+
}
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
logger.error(
|
|
1014
|
+
`${this._tag}::_executeStandardCase off-chain route ${route.type} threw: ${err}`,
|
|
1015
|
+
);
|
|
1016
|
+
await this._emitEvent(ExecutionEventType.FAILURE, {
|
|
1017
|
+
routeType: route.type,
|
|
1018
|
+
error: `${err}`,
|
|
1019
|
+
});
|
|
1020
|
+
results.push(this._failureResult(route));
|
|
1021
|
+
return results;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Flush remaining on-chain calls
|
|
1026
|
+
if (batchCalls.length > 0) {
|
|
1027
|
+
const batchResult = this._onChainBatchResult(batchCalls);
|
|
1028
|
+
results.push(batchResult);
|
|
1029
|
+
await this._emitEvent(ExecutionEventType.INITIATED, {
|
|
1030
|
+
routeSummary: `on-chain batch (${batchCalls.length} calls)`,
|
|
1031
|
+
calls: batchCalls,
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
return results;
|
|
1036
|
+
} finally {
|
|
1037
|
+
this._clearAdapterPriceInfo();
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1042
|
+
// Private — coordinated dual-exchange execution
|
|
1043
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Coordinated execution for cases that touch both Vesu (on-chain) and
|
|
1047
|
+
* Extended (off-chain) exposure in the same case.
|
|
1048
|
+
*
|
|
1049
|
+
* Flow:
|
|
1050
|
+
* 1. Partition active routes into on-chain vs Extended
|
|
1051
|
+
* 2. Construct all on-chain calls in parallel
|
|
1052
|
+
* 3. Estimate on-chain batch (dry-run via account.estimateInvokeFee)
|
|
1053
|
+
* 4. Validate price divergence between Extended and AVNU
|
|
1054
|
+
* 5. Race Extended limit order fill against timeout
|
|
1055
|
+
* 6. If filled → return Extended result + on-chain batch
|
|
1056
|
+
* If timeout/failure → drop everything
|
|
1057
|
+
*
|
|
1058
|
+
* // TODO: If Extended fills but the orchestrator's on-chain multicall later
|
|
1059
|
+
* // reverts, we have an unhedged Extended position. The orchestrator should
|
|
1060
|
+
* // catch the on-chain failure, re-solve with updated state to recompute
|
|
1061
|
+
* // necessary on-chain calls, and retry. This recovery is non-trivial and
|
|
1062
|
+
* // should be implemented as a follow-up.
|
|
1063
|
+
*/
|
|
1064
|
+
private async _executeCoordinatedCase(
|
|
1065
|
+
caseEntry: SolveCaseEntry,
|
|
1066
|
+
activeRoutes: ExecutionRoute[],
|
|
1067
|
+
): Promise<TransactionResult[]> {
|
|
1068
|
+
const results: TransactionResult[] = [];
|
|
1069
|
+
this._clearAdapterPriceInfo();
|
|
1070
|
+
|
|
1071
|
+
try {
|
|
1072
|
+
// ── Step 1: Partition routes ──────────────────────────────────────
|
|
1073
|
+
const onChainRoutes = activeRoutes.filter((r) => this._isOnChainRoute(r));
|
|
1074
|
+
const extendedRoute = activeRoutes.find((r) =>
|
|
1075
|
+
ExecutionService.EXTENDED_EXPOSURE_ROUTES.has(r.type),
|
|
1076
|
+
) as ExtendedLeverRoute | undefined;
|
|
1077
|
+
|
|
1078
|
+
if (!extendedRoute) {
|
|
1079
|
+
logger.error(
|
|
1080
|
+
`${this._tag}::_executeCoordinatedCase no Extended exposure route found ` +
|
|
1081
|
+
`in case "${caseEntry.case.id}" — falling back to standard path`,
|
|
1082
|
+
);
|
|
1083
|
+
return this._executeStandardCase(caseEntry, activeRoutes);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// set extended leverage
|
|
1087
|
+
const setLevResult = await this._config.extendedAdapter.setLeverage(calculateExtendedLevergae().toString(), this._config.extendedAdapter.config.extendedMarketName);
|
|
1088
|
+
if (!setLevResult) {
|
|
1089
|
+
logger.error(
|
|
1090
|
+
`${this._tag}::_executeCoordinatedCase failed to set leverage`,
|
|
1091
|
+
);
|
|
1092
|
+
results.push(this._failureResult(extendedRoute));
|
|
1093
|
+
return results;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const isIncrease = ExecutionService.INCREASE_EXPOSURE_ROUTES.has(
|
|
1097
|
+
extendedRoute.type,
|
|
1098
|
+
);
|
|
1099
|
+
const side = isIncrease ? OrderSide.SELL : OrderSide.BUY;
|
|
1100
|
+
const btcAmount = extendedRoute.amount.toNumber();
|
|
1101
|
+
|
|
1102
|
+
logger.info(
|
|
1103
|
+
`${this._tag}::_executeCoordinatedCase case="${caseEntry.case.id}" ` +
|
|
1104
|
+
`onChainRoutes=${onChainRoutes.length} extended=${extendedRoute.type} ` +
|
|
1105
|
+
`side=${side} btcAmount=${btcAmount}`,
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
// ── Step 2: Construct all on-chain calls in parallel ─────────────
|
|
1109
|
+
let onChainCalls: Call[];
|
|
1110
|
+
try {
|
|
1111
|
+
const callArrays = await Promise.all(
|
|
1112
|
+
onChainRoutes.map(async (r) => {
|
|
1113
|
+
const output = await this._buildOnChainCalls(r);
|
|
1114
|
+
logger.verbose(`${this._tag}::_executeCoordinatedCase on-chain call construction output: ${r.type} ${JSON.stringify(output)}`);
|
|
1115
|
+
return output;
|
|
1116
|
+
}),
|
|
1117
|
+
);
|
|
1118
|
+
onChainCalls = callArrays.flat();
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
logger.error(
|
|
1121
|
+
`${this._tag}::_executeCoordinatedCase on-chain call construction failed: ${err}`,
|
|
1122
|
+
);
|
|
1123
|
+
await this._emitEvent(ExecutionEventType.FAILURE, {
|
|
1124
|
+
routeSummary: `coordinated on-chain build for case "${caseEntry.case.id}"`,
|
|
1125
|
+
error: `${err}`,
|
|
1126
|
+
});
|
|
1127
|
+
results.push(this._failureResult(extendedRoute));
|
|
1128
|
+
return results;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (onChainCalls.length === 0) {
|
|
1132
|
+
logger.warn(
|
|
1133
|
+
`${this._tag}::_executeCoordinatedCase on-chain routes produced 0 calls — aborting`,
|
|
1134
|
+
);
|
|
1135
|
+
results.push(this._failureResult(extendedRoute));
|
|
1136
|
+
return results;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// ── Step 3: Estimate on-chain batch ──────────────────────────────
|
|
1140
|
+
try {
|
|
1141
|
+
await this._account!.estimateInvokeFee(onChainCalls);
|
|
1142
|
+
logger.info(
|
|
1143
|
+
`${this._tag}::_executeCoordinatedCase on-chain estimation passed ` +
|
|
1144
|
+
`(${onChainCalls.length} calls)`,
|
|
1145
|
+
);
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
logger.error(
|
|
1148
|
+
`${this._tag}::_executeCoordinatedCase on-chain estimation failed: ${err}`,
|
|
1149
|
+
);
|
|
1150
|
+
await this._emitEvent(ExecutionEventType.FAILURE, {
|
|
1151
|
+
routeSummary: `coordinated estimation for case "${caseEntry.case.id}"`,
|
|
1152
|
+
error: `${err}`,
|
|
1153
|
+
});
|
|
1154
|
+
results.push(this._failureResult(extendedRoute));
|
|
1155
|
+
return results;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// ── Step 4: Resolve ideal execution price and validate divergence when swap is present ──
|
|
1159
|
+
const hasVesuSwapRoute = onChainRoutes.some(
|
|
1160
|
+
(r) =>
|
|
1161
|
+
r.type === RouteType.VESU_MULTIPLY_INCREASE_LEVER ||
|
|
1162
|
+
r.type === RouteType.VESU_MULTIPLY_DECREASE_LEVER
|
|
1163
|
+
);
|
|
1164
|
+
const netExecutionPrice = this._getNetExecutionPrice(isIncrease);
|
|
1165
|
+
let executionPrice: number;
|
|
1166
|
+
|
|
1167
|
+
if (hasVesuSwapRoute) {
|
|
1168
|
+
if (netExecutionPrice === null) {
|
|
1169
|
+
const errMsg =
|
|
1170
|
+
`Swap route present but execution price unavailable after on-chain call build`;
|
|
1171
|
+
logger.error(`${this._tag}::_executeCoordinatedCase ${errMsg}`);
|
|
1172
|
+
results.push(this._failureResult(extendedRoute));
|
|
1173
|
+
return results;
|
|
1174
|
+
}
|
|
1175
|
+
executionPrice = netExecutionPrice;
|
|
1176
|
+
try {
|
|
1177
|
+
await this._validatePriceDivergence(isIncrease, executionPrice);
|
|
1178
|
+
} catch (err) {
|
|
1179
|
+
logger.error(
|
|
1180
|
+
`${this._tag}::_executeCoordinatedCase price divergence check failed: ${err}`,
|
|
1181
|
+
);
|
|
1182
|
+
await this._emitEvent(ExecutionEventType.FAILURE, {
|
|
1183
|
+
routeSummary: `coordinated price divergence for case "${caseEntry.case.id}"`,
|
|
1184
|
+
error: `${err}`,
|
|
1185
|
+
});
|
|
1186
|
+
results.push(this._failureResult(extendedRoute));
|
|
1187
|
+
return results;
|
|
1188
|
+
}
|
|
1189
|
+
} else {
|
|
1190
|
+
const { bid, ask } = await this._config.extendedAdapter.fetchOrderBookFromExtended();
|
|
1191
|
+
if (bid.lessThanOrEqualTo(0) || ask.lessThanOrEqualTo(0)) {
|
|
1192
|
+
const errMsg =
|
|
1193
|
+
`No AVNU swap route and invalid Extended orderbook for fallback price`;
|
|
1194
|
+
logger.error(`${this._tag}::_executeCoordinatedCase ${errMsg}`);
|
|
1195
|
+
results.push(this._failureResult(extendedRoute));
|
|
1196
|
+
return results;
|
|
1197
|
+
}
|
|
1198
|
+
executionPrice = ask.plus(bid).div(2).toNumber();
|
|
1199
|
+
logger.info(
|
|
1200
|
+
`${this._tag}::_executeCoordinatedCase using Extended mid-price fallback=${executionPrice} (no AVNU swap route)`,
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// debug
|
|
1205
|
+
// if (1) throw new Error(`${this._tag}::_executeCoordinatedCase price divergence check passed, executionPrice: ${executionPrice}`);
|
|
1206
|
+
|
|
1207
|
+
// ── Step 5: Fill Extended with timeout ────────────────────────────
|
|
1208
|
+
const timeoutMs =
|
|
1209
|
+
this._config.extendedFillTimeoutMs ?? DEFAULT_EXTENDED_FILL_TIMEOUT_MS;
|
|
1210
|
+
const extResult = await this._executeExtendedLimitOrderWithRecovery(
|
|
1211
|
+
btcAmount,
|
|
1212
|
+
executionPrice,
|
|
1213
|
+
side,
|
|
1214
|
+
{
|
|
1215
|
+
maxAttempts: 1,
|
|
1216
|
+
timeoutMs,
|
|
1217
|
+
contextTag: "_executeCoordinatedCase",
|
|
1218
|
+
},
|
|
1219
|
+
);
|
|
1220
|
+
|
|
1221
|
+
if (!extResult) {
|
|
1222
|
+
logger.error(
|
|
1223
|
+
`${this._tag}::_executeCoordinatedCase Extended fill failed or timed out ` +
|
|
1224
|
+
`— dropping all calls for case "${caseEntry.case.id}"`,
|
|
1225
|
+
);
|
|
1226
|
+
await this._emitEvent(ExecutionEventType.FAILURE, {
|
|
1227
|
+
routeSummary: `coordinated Extended fill for case "${caseEntry.case.id}"`,
|
|
1228
|
+
error: 'Extended fill failed or timed out',
|
|
1229
|
+
});
|
|
1230
|
+
results.push(this._failureResult(extendedRoute));
|
|
1231
|
+
return results;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// ── Step 6: Extended filled — return results ─────────────────────
|
|
1235
|
+
logger.info(
|
|
1236
|
+
`${this._tag}::_executeCoordinatedCase Extended filled ` +
|
|
1237
|
+
`orderId=${extResult.position_id} execPrice=${extResult.executionPrice} ` +
|
|
1238
|
+
`— returning on-chain batch (${onChainCalls.length} calls)`,
|
|
1239
|
+
);
|
|
1240
|
+
|
|
1241
|
+
await this._emitEvent(ExecutionEventType.SUCCESS, {
|
|
1242
|
+
routeType: extendedRoute.type,
|
|
1243
|
+
orderId: extResult.position_id,
|
|
1244
|
+
amount: extResult.btc_exposure,
|
|
1245
|
+
executionPrice: extResult.executionPrice,
|
|
1246
|
+
protocol: 'EXTENDED',
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
const extendedTxResult = this._successResult(
|
|
1250
|
+
[],
|
|
1251
|
+
isIncrease ? Protocols.NONE.name : Protocols.EXTENDED.name,
|
|
1252
|
+
isIncrease ? Protocols.EXTENDED.name : Protocols.NONE.name,
|
|
1253
|
+
isIncrease ? "DEPOSIT" : "WITHDRAWAL",
|
|
1254
|
+
extendedRoute.amount,
|
|
1255
|
+
CycleType.INVESTMENT,
|
|
1256
|
+
);
|
|
1257
|
+
results.push(extendedTxResult);
|
|
1258
|
+
|
|
1259
|
+
const onChainBatchResult = this._onChainBatchResult(onChainCalls);
|
|
1260
|
+
results.push(onChainBatchResult);
|
|
1261
|
+
await this._emitEvent(ExecutionEventType.INITIATED, {
|
|
1262
|
+
routeSummary: `coordinated on-chain batch (${onChainCalls.length} calls)`,
|
|
1263
|
+
calls: onChainCalls,
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
return results;
|
|
1267
|
+
} finally {
|
|
1268
|
+
this._clearAdapterPriceInfo();
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1273
|
+
// Private — route classification
|
|
1274
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1275
|
+
|
|
1276
|
+
/** Returns true if the route produces Starknet Call objects (on-chain tx). */
|
|
1277
|
+
private _isOnChainRoute(route: ExecutionRoute): boolean {
|
|
1278
|
+
switch (route.type) {
|
|
1279
|
+
case RouteType.WALLET_TO_EXTENDED:
|
|
1280
|
+
case RouteType.VA_TO_EXTENDED:
|
|
1281
|
+
case RouteType.WALLET_TO_VA:
|
|
1282
|
+
case RouteType.AVNU_DEPOSIT_SWAP:
|
|
1283
|
+
case RouteType.AVNU_WITHDRAW_SWAP:
|
|
1284
|
+
case RouteType.VESU_MULTIPLY_INCREASE_LEVER:
|
|
1285
|
+
case RouteType.VESU_MULTIPLY_DECREASE_LEVER:
|
|
1286
|
+
case RouteType.VESU_BORROW:
|
|
1287
|
+
case RouteType.VESU_REPAY:
|
|
1288
|
+
case RouteType.BRING_LIQUIDITY:
|
|
1289
|
+
case RouteType.CRISIS_BORROW_BEYOND_TARGET_HF:
|
|
1290
|
+
return true;
|
|
1291
|
+
default:
|
|
1292
|
+
return false;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/** Checks whether any route in the list is a RETURN_TO_WAIT sentinel. */
|
|
1297
|
+
private _caseHitReturnToWait(routes: ExecutionRoute[]): boolean {
|
|
1298
|
+
return routes.some((r) => r.type === RouteType.RETURN_TO_WAIT);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Returns true when the active window contains both on-chain exposure
|
|
1303
|
+
* routes (Vesu/AVNU) and off-chain Extended exposure routes — meaning
|
|
1304
|
+
* both exchanges participate in the same exposure change and should be
|
|
1305
|
+
* executed via the coordinated (fill-or-abort) path.
|
|
1306
|
+
*/
|
|
1307
|
+
private _isDualExchangeCase(activeRoutes: ExecutionRoute[]): boolean {
|
|
1308
|
+
const hasOnChainExposure = activeRoutes.some((r) =>
|
|
1309
|
+
ExecutionService.ON_CHAIN_EXPOSURE_ROUTES.has(r.type),
|
|
1310
|
+
);
|
|
1311
|
+
const hasExtendedExposure = activeRoutes.some((r) =>
|
|
1312
|
+
ExecutionService.EXTENDED_EXPOSURE_ROUTES.has(r.type),
|
|
1313
|
+
);
|
|
1314
|
+
return hasOnChainExposure && hasExtendedExposure;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1318
|
+
// Private — on-chain call builders (return Call[])
|
|
1319
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1320
|
+
|
|
1321
|
+
/** Dispatches an on-chain route to the appropriate call builder. */
|
|
1322
|
+
private async _buildOnChainCalls(route: ExecutionRoute): Promise<Call[]> {
|
|
1323
|
+
switch (route.type) {
|
|
1324
|
+
case RouteType.WALLET_TO_EXTENDED:
|
|
1325
|
+
return this._buildWalletToExtendedCalls(route as TransferRoute);
|
|
1326
|
+
case RouteType.VA_TO_EXTENDED:
|
|
1327
|
+
return this._buildVAToExtendedCalls(route as TransferRoute);
|
|
1328
|
+
case RouteType.WALLET_TO_VA:
|
|
1329
|
+
return this._buildWalletToVACalls(route as TransferRoute);
|
|
1330
|
+
case RouteType.AVNU_DEPOSIT_SWAP:
|
|
1331
|
+
return this._buildAvnuDepositSwapCalls(route as SwapRoute);
|
|
1332
|
+
case RouteType.AVNU_WITHDRAW_SWAP:
|
|
1333
|
+
return this._buildAvnuWithdrawSwapCalls(route as SwapRoute);
|
|
1334
|
+
case RouteType.VESU_MULTIPLY_INCREASE_LEVER:
|
|
1335
|
+
return this._buildVesuIncreaseLeverCalls(route as VesuMultiplyRoute);
|
|
1336
|
+
case RouteType.VESU_MULTIPLY_DECREASE_LEVER:
|
|
1337
|
+
return this._buildVesuDecreaseLeverCalls(route as VesuMultiplyRoute);
|
|
1338
|
+
case RouteType.VESU_BORROW:
|
|
1339
|
+
return this._buildVesuBorrowCalls(route as VesuDebtRoute);
|
|
1340
|
+
case RouteType.VESU_REPAY:
|
|
1341
|
+
return this._buildVesuRepayCalls(route as VesuDebtRoute);
|
|
1342
|
+
case RouteType.BRING_LIQUIDITY:
|
|
1343
|
+
return this._buildBringLiquidityCalls(route as BringLiquidityRoute);
|
|
1344
|
+
case RouteType.CRISIS_BORROW_BEYOND_TARGET_HF:
|
|
1345
|
+
return this._buildCrisisBorrowCalls(route as CrisisBorrowRoute);
|
|
1346
|
+
default:
|
|
1347
|
+
logger.warn(
|
|
1348
|
+
`${this._tag}::_buildOnChainCalls unhandled route type: ${route.type}`,
|
|
1349
|
+
);
|
|
1350
|
+
return [];
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// ── Transfer routes ─────────────────────────────────────────────────────
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* WALLET_TO_EXTENDED: Deposit USDC.e from operator wallet directly to Extended.
|
|
1358
|
+
*
|
|
1359
|
+
* Builds raw approve + deposit calls (NOT through the manager/merkle system)
|
|
1360
|
+
* because the wallet interacts with Extended directly, not via vault allocator.
|
|
1361
|
+
*
|
|
1362
|
+
* Adjusts amount by pending deposit to avoid double-sending.
|
|
1363
|
+
*/
|
|
1364
|
+
private async _buildWalletToExtendedCalls(
|
|
1365
|
+
route: TransferRoute,
|
|
1366
|
+
): Promise<Call[]> {
|
|
1367
|
+
const amount = this._consumePendingDeposit(route.amount);
|
|
1368
|
+
if (amount.lessThanOrEqualTo(0)) {
|
|
1369
|
+
logger.info(
|
|
1370
|
+
`${this._tag}::_buildWalletToExtendedCalls skipped — pending deposit covers amount`,
|
|
1371
|
+
);
|
|
1372
|
+
return [];
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const { usdceToken, extendedAdapter } = this._config;
|
|
1376
|
+
const extendedContract = extendedAdapter.config.extendedContract;
|
|
1377
|
+
const vaultId = extendedAdapter.config.vaultIdExtended;
|
|
1378
|
+
const salt = Math.floor(Math.random() * 10 ** usdceToken.decimals);
|
|
1379
|
+
const uint256Amount = uint256.bnToUint256(amount.toWei());
|
|
1380
|
+
|
|
1381
|
+
const approveCall: Call = {
|
|
1382
|
+
contractAddress: usdceToken.address.address,
|
|
1383
|
+
entrypoint: "approve",
|
|
1384
|
+
calldata: [
|
|
1385
|
+
extendedContract.address,
|
|
1386
|
+
uint256Amount.low.toString(),
|
|
1387
|
+
uint256Amount.high.toString(),
|
|
1388
|
+
],
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
const depositCall: Call = {
|
|
1392
|
+
contractAddress: extendedContract.address,
|
|
1393
|
+
entrypoint: "deposit",
|
|
1394
|
+
calldata: [
|
|
1395
|
+
vaultId.toString(),
|
|
1396
|
+
amount.toWei(),
|
|
1397
|
+
salt.toString(),
|
|
1398
|
+
],
|
|
1399
|
+
};
|
|
1400
|
+
|
|
1401
|
+
logger.info(
|
|
1402
|
+
`${this._tag}::_buildWalletToExtendedCalls built raw approve+deposit ` +
|
|
1403
|
+
`amount=${amount.toNumber()} to Extended contract=${extendedContract.shortString()}`,
|
|
1404
|
+
);
|
|
1405
|
+
|
|
1406
|
+
return [approveCall, depositCall];
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* VA_TO_EXTENDED: Deposit USDC.e from vault allocator to Extended.
|
|
1411
|
+
*
|
|
1412
|
+
* Uses the extended adapter's getDepositCall to build ManageCalls,
|
|
1413
|
+
* then wraps them in a merkle-verified manage call through the manager contract.
|
|
1414
|
+
*
|
|
1415
|
+
* Adjusts amount by pending deposit to avoid double-sending.
|
|
1416
|
+
*/
|
|
1417
|
+
private async _buildVAToExtendedCalls(
|
|
1418
|
+
route: TransferRoute,
|
|
1419
|
+
): Promise<Call[]> {
|
|
1420
|
+
const amount = this._consumePendingDeposit(route.amount);
|
|
1421
|
+
if (amount.lessThanOrEqualTo(0)) {
|
|
1422
|
+
logger.info(
|
|
1423
|
+
`${this._tag}::_buildVAToExtendedCalls skipped — pending deposit covers amount`,
|
|
1424
|
+
);
|
|
1425
|
+
return [];
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// swap call (usdc to usdce)
|
|
1429
|
+
const swapCall = await this._buildAdapterManageCall(
|
|
1430
|
+
this._config.usdcToUsdceAdapter,
|
|
1431
|
+
true,
|
|
1432
|
+
{ amount },
|
|
1433
|
+
);
|
|
1434
|
+
const manageCall = await this._buildAdapterManageCall(
|
|
1435
|
+
this._config.extendedAdapter,
|
|
1436
|
+
true,
|
|
1437
|
+
{ amount },
|
|
1438
|
+
);
|
|
1439
|
+
return [swapCall, manageCall];
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* WALLET_TO_VA: Transfer USDC.e from operator wallet to vault allocator.
|
|
1444
|
+
* Caps amount by actual wallet balance.
|
|
1445
|
+
*/
|
|
1446
|
+
private async _buildWalletToVACalls(
|
|
1447
|
+
route: TransferRoute,
|
|
1448
|
+
): Promise<Call[]> {
|
|
1449
|
+
const erc20 = new ERC20(this._config.networkConfig);
|
|
1450
|
+
const { usdceToken, vaultAllocator, walletAddress } = this._config;
|
|
1451
|
+
|
|
1452
|
+
// checking balance could be unnecesary overhead. hence removed removed for now.
|
|
1453
|
+
|
|
1454
|
+
const transferAmount = route.amount;
|
|
1455
|
+
if (transferAmount.lessThanOrEqualTo(0)) {
|
|
1456
|
+
logger.warn(
|
|
1457
|
+
`${this._tag}::_buildWalletToVACalls no USDC.e in wallet to transfer`,
|
|
1458
|
+
);
|
|
1459
|
+
return [];
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// todo, give infinite approval to VA and extended contract
|
|
1463
|
+
// for USDC.e for wallet account.
|
|
1464
|
+
|
|
1465
|
+
const transferCall = await this._buildAdapterManageCall(
|
|
1466
|
+
this._config.usdceTransferAdapter,
|
|
1467
|
+
false,
|
|
1468
|
+
{ amount: transferAmount },
|
|
1469
|
+
);
|
|
1470
|
+
// swap call (usdc to usdce)
|
|
1471
|
+
const swapCall = await this._buildAdapterManageCall(
|
|
1472
|
+
this._config.usdcToUsdceAdapter,
|
|
1473
|
+
false,
|
|
1474
|
+
{ amount: transferAmount },
|
|
1475
|
+
);
|
|
1476
|
+
return [transferCall, swapCall];
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// ── AVNU swap routes ────────────────────────────────────────────────────
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* AVNU_DEPOSIT_SWAP: Standalone USDC → BTC swap via AVNU.
|
|
1483
|
+
*
|
|
1484
|
+
* Uses avnu adapter's getDepositCall to build swap ManageCalls,
|
|
1485
|
+
* then wraps in a merkle-verified manage call.
|
|
1486
|
+
* Applies 0.1% buffer so downstream Vesu deposit has sufficient BTC.
|
|
1487
|
+
*/
|
|
1488
|
+
private async _buildAvnuDepositSwapCalls(
|
|
1489
|
+
route: SwapRoute,
|
|
1490
|
+
): Promise<Call[]> {
|
|
1491
|
+
// @deprecated
|
|
1492
|
+
// BCZ, we now use vesu multiply margin swap
|
|
1493
|
+
|
|
1494
|
+
// logger.verbose(`${this._tag}::_buildAvnuDepositSwapCalls fromAmount: ${route.fromAmount.toNumber()}`);
|
|
1495
|
+
// const manageCall = await this._buildAdapterManageCall(
|
|
1496
|
+
// this._config.avnuAdapter,
|
|
1497
|
+
// true,
|
|
1498
|
+
// route.fromAmount,
|
|
1499
|
+
// );
|
|
1500
|
+
// return [manageCall];
|
|
1501
|
+
return [];
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* AVNU_WITHDRAW_SWAP: Standalone BTC → USDC swap via AVNU.
|
|
1506
|
+
*
|
|
1507
|
+
* Uses avnu adapter's getWithdrawCall to build swap ManageCalls,
|
|
1508
|
+
* then wraps in a merkle-verified manage call.
|
|
1509
|
+
* Applies 0.1% buffer so Vesu-returned BTC is sufficient for the swap.
|
|
1510
|
+
*/
|
|
1511
|
+
private async _buildAvnuWithdrawSwapCalls(
|
|
1512
|
+
route: SwapRoute,
|
|
1513
|
+
): Promise<Call[]> {
|
|
1514
|
+
// @deprecated
|
|
1515
|
+
// BCZ, we now use vesu multiply withdraw swap
|
|
1516
|
+
|
|
1517
|
+
// const fromAmount =
|
|
1518
|
+
// route.fromAmount.toNumber() > 0
|
|
1519
|
+
// ? route.fromAmount
|
|
1520
|
+
// : new Web3Number(0, this._config.wbtcToken.decimals);
|
|
1521
|
+
// if (fromAmount.lessThanOrEqualTo(0)) {
|
|
1522
|
+
// logger.warn(
|
|
1523
|
+
// `${this._tag}::_buildAvnuWithdrawSwapCalls fromAmount=0 — skipping`,
|
|
1524
|
+
// );
|
|
1525
|
+
// return [];
|
|
1526
|
+
// }
|
|
1527
|
+
|
|
1528
|
+
// const bufferedAmount = this._applyAvnuBuffer(
|
|
1529
|
+
// fromAmount,
|
|
1530
|
+
// this._config.wbtcToken.decimals,
|
|
1531
|
+
// );
|
|
1532
|
+
// const manageCall = await this._buildAdapterManageCall(
|
|
1533
|
+
// this._config.avnuAdapter,
|
|
1534
|
+
// false,
|
|
1535
|
+
// bufferedAmount,
|
|
1536
|
+
// );
|
|
1537
|
+
// return [manageCall];
|
|
1538
|
+
return [];
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// ── Vesu multiply routes ───────────────────────────────────────────────
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* VESU_MULTIPLY_INCREASE_LEVER: Compound operation.
|
|
1545
|
+
* 1. AVNU swap USDC → BTC (with 0.1% buffer on collateral amount)
|
|
1546
|
+
* 2. Vesu multiply deposit — add BTC as collateral (triggers flash-loan leverage)
|
|
1547
|
+
*
|
|
1548
|
+
* Both steps go through the merkle-verified manager contract via their respective adapters.
|
|
1549
|
+
*/
|
|
1550
|
+
private async _buildVesuIncreaseLeverCalls(
|
|
1551
|
+
route: VesuMultiplyRoute,
|
|
1552
|
+
): Promise<Call[]> {
|
|
1553
|
+
// const bufferedCollateral = this._applyAvnuBuffer(
|
|
1554
|
+
// route.collateralAmount,
|
|
1555
|
+
// route.collateralToken.decimals,
|
|
1556
|
+
// );
|
|
1557
|
+
|
|
1558
|
+
const depositManageCall = await this._buildAdapterManageCall(
|
|
1559
|
+
this._config.vesuAdapter,
|
|
1560
|
+
true,
|
|
1561
|
+
{
|
|
1562
|
+
amount: route.marginAmount,
|
|
1563
|
+
marginSwap: {
|
|
1564
|
+
marginToken: this._config.vesuAdapter.config.marginToken, // todo, must be vault token
|
|
1565
|
+
},
|
|
1566
|
+
leverSwap: {
|
|
1567
|
+
exactOutput: route.swappedCollateralAmount,
|
|
1568
|
+
},
|
|
1569
|
+
},
|
|
1570
|
+
);
|
|
1571
|
+
return [depositManageCall];
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* VESU_MULTIPLY_DECREASE_LEVER: Compound operation.
|
|
1576
|
+
* 1. Vesu multiply withdraw — reduce BTC collateral (repays proportional debt via flash-loan)
|
|
1577
|
+
* 2. AVNU swap BTC → USDC (with 0.1% buffer to account for slippage)
|
|
1578
|
+
*
|
|
1579
|
+
* Both steps go through the merkle-verified manager contract via their respective adapters.
|
|
1580
|
+
*/
|
|
1581
|
+
private async _buildVesuDecreaseLeverCalls(
|
|
1582
|
+
route: VesuMultiplyRoute,
|
|
1583
|
+
): Promise<Call[]> {
|
|
1584
|
+
const collateralAmount = route.marginAmount.abs();
|
|
1585
|
+
|
|
1586
|
+
const withdrawManageCall = await this._buildAdapterManageCall(
|
|
1587
|
+
this._config.vesuAdapter,
|
|
1588
|
+
false,
|
|
1589
|
+
{
|
|
1590
|
+
amount: collateralAmount,
|
|
1591
|
+
withdrawSwap: { outputToken: this._config.vesuAdapter.config.marginToken },
|
|
1592
|
+
},
|
|
1593
|
+
);
|
|
1594
|
+
return [withdrawManageCall];
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// ── Vesu debt routes ───────────────────────────────────────────────────
|
|
1598
|
+
|
|
1599
|
+
private _validateVesuDebtRoute(route: VesuDebtRoute): void {
|
|
1600
|
+
const adapterConfig = this._config.vesuModifyPositionAdapter.config;
|
|
1601
|
+
if (!adapterConfig.poolId.eq(route.poolId)) {
|
|
1602
|
+
throw new Error(
|
|
1603
|
+
`${this._tag}::_validateVesuDebtRoute pool mismatch: route=${route.poolId.shortString()} adapter=${adapterConfig.poolId.shortString()}`,
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
if (!adapterConfig.collateral.address.eq(route.collateralToken.address)) {
|
|
1607
|
+
throw new Error(
|
|
1608
|
+
`${this._tag}::_validateVesuDebtRoute collateral mismatch: route=${route.collateralToken.symbol} adapter=${adapterConfig.collateral.symbol}`,
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
if (!adapterConfig.debt.address.eq(route.debtToken.address)) {
|
|
1612
|
+
throw new Error(
|
|
1613
|
+
`${this._tag}::_validateVesuDebtRoute debt mismatch: route=${route.debtToken.symbol} adapter=${adapterConfig.debt.symbol}`,
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* VESU_BORROW: Borrow additional USDC from Vesu.
|
|
1620
|
+
*/
|
|
1621
|
+
private async _buildVesuBorrowCalls(
|
|
1622
|
+
route: VesuDebtRoute,
|
|
1623
|
+
): Promise<Call[]> {
|
|
1624
|
+
this._validateVesuDebtRoute(route);
|
|
1625
|
+
const debtAmount = route.amount.abs();
|
|
1626
|
+
if (debtAmount.lessThanOrEqualTo(0)) {
|
|
1627
|
+
logger.info(`${this._tag}::_buildVesuBorrowCalls skipped amount <= 0`);
|
|
1628
|
+
return [];
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const zeroCollateralAmount = new Web3Number(
|
|
1632
|
+
0,
|
|
1633
|
+
this._config.vesuModifyPositionAdapter.config.collateral.decimals,
|
|
1634
|
+
);
|
|
1635
|
+
const params: VesuModifyPositionDepositParams = {
|
|
1636
|
+
amount: zeroCollateralAmount,
|
|
1637
|
+
debtAmount,
|
|
1638
|
+
};
|
|
1639
|
+
const manageCall =
|
|
1640
|
+
await this._buildAdapterManageCall<VesuModifyPositionDepositParams>(
|
|
1641
|
+
this._config.vesuModifyPositionAdapter,
|
|
1642
|
+
true,
|
|
1643
|
+
params,
|
|
1644
|
+
);
|
|
1645
|
+
return [manageCall];
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* VESU_REPAY: Repay USDC debt to Vesu.
|
|
1650
|
+
*/
|
|
1651
|
+
private async _buildVesuRepayCalls(
|
|
1652
|
+
route: VesuDebtRoute,
|
|
1653
|
+
): Promise<Call[]> {
|
|
1654
|
+
this._validateVesuDebtRoute(route);
|
|
1655
|
+
const debtAmount = route.amount.abs();
|
|
1656
|
+
if (debtAmount.lessThanOrEqualTo(0)) {
|
|
1657
|
+
logger.info(`${this._tag}::_buildVesuRepayCalls skipped amount <= 0`);
|
|
1658
|
+
return [];
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const zeroCollateralAmount = new Web3Number(
|
|
1662
|
+
0,
|
|
1663
|
+
this._config.vesuModifyPositionAdapter.config.collateral.decimals,
|
|
1664
|
+
);
|
|
1665
|
+
const params: VesuModifyPositionWithdrawParams = {
|
|
1666
|
+
amount: zeroCollateralAmount,
|
|
1667
|
+
debtAmount,
|
|
1668
|
+
};
|
|
1669
|
+
const manageCall =
|
|
1670
|
+
await this._buildAdapterManageCall<VesuModifyPositionWithdrawParams>(
|
|
1671
|
+
this._config.vesuModifyPositionAdapter,
|
|
1672
|
+
false,
|
|
1673
|
+
params,
|
|
1674
|
+
);
|
|
1675
|
+
return [manageCall];
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// ── Bring liquidity route ──────────────────────────────────────────────
|
|
1679
|
+
|
|
1680
|
+
/** BRING_LIQUIDITY: Transfer from VA to vault contract for user withdrawals. */
|
|
1681
|
+
private async _buildBringLiquidityCalls(
|
|
1682
|
+
route: BringLiquidityRoute,
|
|
1683
|
+
): Promise<Call[]> {
|
|
1684
|
+
const bringLiquidityCall = await this._config.getBringLiquidityCall({
|
|
1685
|
+
amount: route.amount,
|
|
1686
|
+
});
|
|
1687
|
+
return [bringLiquidityCall];
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// ── Crisis routes ──────────────────────────────────────────────────────
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* CRISIS_BORROW_BEYOND_TARGET_HF: Borrow beyond normal target HF.
|
|
1694
|
+
* TODO: Implement crisis borrow flow when needed.
|
|
1695
|
+
*/
|
|
1696
|
+
private async _buildCrisisBorrowCalls(
|
|
1697
|
+
_route: CrisisBorrowRoute,
|
|
1698
|
+
): Promise<Call[]> {
|
|
1699
|
+
// TODO: Implement crisis borrow calls
|
|
1700
|
+
throw new Error(
|
|
1701
|
+
`${this._tag}::_buildCrisisBorrowCalls not yet implemented`,
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1706
|
+
// Private — off-chain route executors
|
|
1707
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1708
|
+
|
|
1709
|
+
/** Dispatches an off-chain route to the appropriate executor. */
|
|
1710
|
+
private async _executeOffChainRoute(
|
|
1711
|
+
route: ExecutionRoute,
|
|
1712
|
+
): Promise<TransactionResult | null> {
|
|
1713
|
+
switch (route.type) {
|
|
1714
|
+
case RouteType.EXTENDED_TO_WALLET:
|
|
1715
|
+
return this._executeExtendedToWallet(route as TransferRoute);
|
|
1716
|
+
case RouteType.REALISE_PNL:
|
|
1717
|
+
return this._executeRealisePnl(route as RealisePnlRoute);
|
|
1718
|
+
case RouteType.EXTENDED_INCREASE_LEVER:
|
|
1719
|
+
return this._executeExtendedIncreaseLever(route as ExtendedLeverRoute);
|
|
1720
|
+
case RouteType.EXTENDED_DECREASE_LEVER:
|
|
1721
|
+
return this._executeExtendedDecreaseLever(route as ExtendedLeverRoute);
|
|
1722
|
+
case RouteType.CRISIS_INCREASE_EXTENDED_MAX_LEVERAGE:
|
|
1723
|
+
return this._executeCrisisIncreaseLeverage();
|
|
1724
|
+
case RouteType.CRISIS_UNDO_EXTENDED_MAX_LEVERAGE:
|
|
1725
|
+
return this._executeCrisisUndoLeverage();
|
|
1726
|
+
default:
|
|
1727
|
+
logger.warn(
|
|
1728
|
+
`${this._tag}::_executeOffChainRoute unhandled route type: ${route.type}`,
|
|
1729
|
+
);
|
|
1730
|
+
return null;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// ── Extended withdrawal ─────────────────────────────────────────────────
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* EXTENDED_TO_WALLET: Withdraw from Extended exchange to operator wallet.
|
|
1738
|
+
*
|
|
1739
|
+
* This initiates the off-chain withdrawal API call. When followed by
|
|
1740
|
+
* RETURN_TO_WAIT, the executor will halt and the next solve cycle picks up
|
|
1741
|
+
* once the withdrawal settles and wallet balance is updated.
|
|
1742
|
+
*
|
|
1743
|
+
* If a pending withdrawal is already in transit (pendingDeposit < 0),
|
|
1744
|
+
* the amount is reduced accordingly.
|
|
1745
|
+
*/
|
|
1746
|
+
private async _executeExtendedToWallet(
|
|
1747
|
+
route: TransferRoute,
|
|
1748
|
+
): Promise<TransactionResult> {
|
|
1749
|
+
let amount = route.amount;
|
|
1750
|
+
|
|
1751
|
+
if (this._pendingDepositRemaining < 0) {
|
|
1752
|
+
const alreadyWithdrawing = Math.abs(this._pendingDepositRemaining);
|
|
1753
|
+
const adjusted = amount.toNumber() - alreadyWithdrawing;
|
|
1754
|
+
if (adjusted <= 0) {
|
|
1755
|
+
logger.info(
|
|
1756
|
+
`${this._tag}::_executeExtendedToWallet skipped — pending withdrawal covers ${route.amount.toNumber()}`,
|
|
1757
|
+
);
|
|
1758
|
+
this._pendingDepositRemaining += amount.toNumber();
|
|
1759
|
+
return this._successResult(
|
|
1760
|
+
[],
|
|
1761
|
+
Protocols.EXTENDED.name,
|
|
1762
|
+
Protocols.NONE.name,
|
|
1763
|
+
"WITHDRAWAL",
|
|
1764
|
+
route.amount,
|
|
1765
|
+
CycleType.INVESTMENT,
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
this._pendingDepositRemaining = 0;
|
|
1769
|
+
amount = new Web3Number(
|
|
1770
|
+
adjusted.toFixed(this._config.usdcToken.decimals),
|
|
1771
|
+
this._config.usdcToken.decimals,
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
await this._emitEvent(ExecutionEventType.INITIATED, {
|
|
1776
|
+
routeType: RouteType.EXTENDED_TO_WALLET,
|
|
1777
|
+
protocol: 'EXTENDED',
|
|
1778
|
+
amount: amount.toNumber().toString(),
|
|
1779
|
+
routeSummary: `withdraw ${amount.toNumber()} from Extended`,
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
const { status } =
|
|
1783
|
+
await this._config.extendedAdapter.withdrawFromExtended(amount);
|
|
1784
|
+
|
|
1785
|
+
if (!status) {
|
|
1786
|
+
logger.error(
|
|
1787
|
+
`${this._tag}::_executeExtendedToWallet withdrawal request failed`,
|
|
1788
|
+
);
|
|
1789
|
+
await this._emitEvent(ExecutionEventType.FAILURE, {
|
|
1790
|
+
routeType: RouteType.EXTENDED_TO_WALLET,
|
|
1791
|
+
error: 'Withdrawal request failed',
|
|
1792
|
+
});
|
|
1793
|
+
return this._failureResult(route);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
logger.info(
|
|
1797
|
+
`${this._tag}::_executeExtendedToWallet initiated withdrawal of ${amount.toNumber()}`,
|
|
1798
|
+
);
|
|
1799
|
+
|
|
1800
|
+
await this._emitEvent(ExecutionEventType.SUCCESS, {
|
|
1801
|
+
routeType: RouteType.EXTENDED_TO_WALLET,
|
|
1802
|
+
amount: amount.toNumber().toString(),
|
|
1803
|
+
protocol: 'EXTENDED',
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
return this._successResult(
|
|
1807
|
+
[],
|
|
1808
|
+
Protocols.EXTENDED.name,
|
|
1809
|
+
Protocols.NONE.name,
|
|
1810
|
+
"WITHDRAWAL",
|
|
1811
|
+
amount,
|
|
1812
|
+
CycleType.INVESTMENT,
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// ── Realise PnL ─────────────────────────────────────────────────────────
|
|
1817
|
+
|
|
1818
|
+
/**
|
|
1819
|
+
* REALISE_PNL: Converts unrealised PnL to realised by closing a portion
|
|
1820
|
+
* of the short position and immediately reopening it.
|
|
1821
|
+
*
|
|
1822
|
+
* Uses {@link calculatePositionToCloseToWithdrawAmount} to determine the
|
|
1823
|
+
* minimal position size to close so that the required amount becomes
|
|
1824
|
+
* available for withdrawal.
|
|
1825
|
+
*
|
|
1826
|
+
* Both close and reopen orders use limit pricing via _executeExtendedLimitOrder.
|
|
1827
|
+
*
|
|
1828
|
+
* Steps:
|
|
1829
|
+
* 1. Close portion of short position (BUY limit order) → realises PnL
|
|
1830
|
+
* 2. Immediately reopen same size (SELL limit order) → maintains exposure
|
|
1831
|
+
*/
|
|
1832
|
+
private async _executeRealisePnl(
|
|
1833
|
+
route: RealisePnlRoute,
|
|
1834
|
+
): Promise<TransactionResult> {
|
|
1835
|
+
const adapter = this._config.extendedAdapter;
|
|
1836
|
+
|
|
1837
|
+
const [positions, holdings] = await Promise.all([
|
|
1838
|
+
adapter.getAllOpenPositions(),
|
|
1839
|
+
adapter.getExtendedDepositAmount(),
|
|
1840
|
+
]);
|
|
1841
|
+
|
|
1842
|
+
if (!positions?.length || !holdings) {
|
|
1843
|
+
logger.error(
|
|
1844
|
+
`${this._tag}::_executeRealisePnl could not fetch position/balance`,
|
|
1845
|
+
);
|
|
1846
|
+
return this._failureResult(route);
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
const position = positions.find((p) => p.market === route.instrument);
|
|
1850
|
+
if (!position) {
|
|
1851
|
+
logger.error(
|
|
1852
|
+
`${this._tag}::_executeRealisePnl no position for ${route.instrument}`,
|
|
1853
|
+
);
|
|
1854
|
+
return this._failureResult(route);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
const positionToClose = await calculatePositionToCloseToWithdrawAmount(
|
|
1858
|
+
holdings,
|
|
1859
|
+
position,
|
|
1860
|
+
route.amount,
|
|
1861
|
+
);
|
|
1862
|
+
|
|
1863
|
+
if (positionToClose.lessThanOrEqualTo(0)) {
|
|
1864
|
+
logger.info(
|
|
1865
|
+
`${this._tag}::_executeRealisePnl amount already available — no close needed`,
|
|
1866
|
+
);
|
|
1867
|
+
return this._successResult(
|
|
1868
|
+
[],
|
|
1869
|
+
Protocols.EXTENDED.name,
|
|
1870
|
+
Protocols.EXTENDED.name,
|
|
1871
|
+
"NONE",
|
|
1872
|
+
route.amount,
|
|
1873
|
+
CycleType.INVESTMENT,
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// Step 1: Close portion of short (BUY limit order to realise PnL)
|
|
1878
|
+
logger.info(
|
|
1879
|
+
`${this._tag}::_executeRealisePnl closing ${positionToClose.toNumber()} on ${route.instrument}`,
|
|
1880
|
+
);
|
|
1881
|
+
await this._emitEvent(ExecutionEventType.INITIATED, {
|
|
1882
|
+
routeType: RouteType.REALISE_PNL,
|
|
1883
|
+
routeSummary: `close ${positionToClose.toNumber()} of short`,
|
|
1884
|
+
protocol: 'EXTENDED',
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
const midPrice = await this._getExtendedMidPrice();
|
|
1888
|
+
if (!midPrice) {
|
|
1889
|
+
logger.error(
|
|
1890
|
+
`${this._tag}::_executeRealisePnl could not get extended mid price`,
|
|
1891
|
+
);
|
|
1892
|
+
return this._failureResult(route);
|
|
1893
|
+
}
|
|
1894
|
+
const closeResult = await this._executeExtendedLimitOrderWithRecovery(
|
|
1895
|
+
positionToClose.toNumber(),
|
|
1896
|
+
midPrice,
|
|
1897
|
+
OrderSide.BUY,
|
|
1898
|
+
);
|
|
1899
|
+
if (!closeResult) {
|
|
1900
|
+
logger.error(
|
|
1901
|
+
`${this._tag}::_executeRealisePnl close limit order failed`,
|
|
1902
|
+
);
|
|
1903
|
+
await this._emitEvent(ExecutionEventType.FAILURE, {
|
|
1904
|
+
routeType: RouteType.REALISE_PNL,
|
|
1905
|
+
error: 'Close limit order failed',
|
|
1906
|
+
});
|
|
1907
|
+
return this._failureResult(route);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// Step 2: Immediately reopen same position size (SELL limit order to maintain exposure)
|
|
1911
|
+
logger.info(
|
|
1912
|
+
`${this._tag}::_executeRealisePnl reopening ${positionToClose.toNumber()} on ${route.instrument}`,
|
|
1913
|
+
);
|
|
1914
|
+
// todo need to ensure this one passes for sure
|
|
1915
|
+
const reopenResult = await this._executeExtendedLimitOrderWithRecovery(
|
|
1916
|
+
positionToClose.toNumber(),
|
|
1917
|
+
midPrice,
|
|
1918
|
+
OrderSide.SELL,
|
|
1919
|
+
);
|
|
1920
|
+
if (!reopenResult) {
|
|
1921
|
+
logger.error(
|
|
1922
|
+
`${this._tag}::_executeRealisePnl reopen limit order failed — WARNING: exposure reduced`,
|
|
1923
|
+
);
|
|
1924
|
+
await this._emitEvent(ExecutionEventType.FAILURE, {
|
|
1925
|
+
routeType: RouteType.REALISE_PNL,
|
|
1926
|
+
error: 'Reopen limit order failed — exposure reduced',
|
|
1927
|
+
});
|
|
1928
|
+
return this._failureResult(route);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
logger.info(
|
|
1932
|
+
`${this._tag}::_executeRealisePnl realised PnL via close+reopen of ${positionToClose.toNumber()} ` +
|
|
1933
|
+
`closePrice=${closeResult.executionPrice} reopenPrice=${reopenResult.executionPrice}`,
|
|
1934
|
+
);
|
|
1935
|
+
|
|
1936
|
+
await this._emitEvent(ExecutionEventType.SUCCESS, {
|
|
1937
|
+
routeType: RouteType.REALISE_PNL,
|
|
1938
|
+
orderId: `close:${closeResult.position_id},reopen:${reopenResult.position_id}`,
|
|
1939
|
+
amount: positionToClose.toNumber().toString(),
|
|
1940
|
+
executionPrice: closeResult.executionPrice,
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
return this._successResult(
|
|
1944
|
+
[],
|
|
1945
|
+
Protocols.EXTENDED.name,
|
|
1946
|
+
Protocols.EXTENDED.name,
|
|
1947
|
+
"NONE",
|
|
1948
|
+
route.amount,
|
|
1949
|
+
CycleType.INVESTMENT,
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// ── Extended lever routes ───────────────────────────────────────────────
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* EXTENDED_INCREASE_LEVER: Create a SHORT sell limit order on Extended to
|
|
1957
|
+
* increase delta-neutral exposure.
|
|
1958
|
+
*
|
|
1959
|
+
* Uses limit pricing: limitPrice = midPrice * (1 - slippage) to prevent
|
|
1960
|
+
* selling at unfavourable rates.
|
|
1961
|
+
*
|
|
1962
|
+
* Validates price divergence between Extended and Vesu before execution.
|
|
1963
|
+
*/
|
|
1964
|
+
private async _executeExtendedIncreaseLever(
|
|
1965
|
+
route: ExtendedLeverRoute,
|
|
1966
|
+
): Promise<TransactionResult> {
|
|
1967
|
+
logger.info(
|
|
1968
|
+
`${this._tag}::_executeExtendedIncreaseLever SHORT ${route.amount.toNumber()} on ${route.instrument}`,
|
|
1969
|
+
);
|
|
1970
|
+
|
|
1971
|
+
const midPrice = await this._getExtendedMidPrice();
|
|
1972
|
+
if (!midPrice) {
|
|
1973
|
+
logger.error(
|
|
1974
|
+
`${this._tag}::_executeExtendedIncreaseLever could not get extended mid price`,
|
|
1975
|
+
);
|
|
1976
|
+
return this._failureResult(route);
|
|
1977
|
+
}
|
|
1978
|
+
const result = await this._executeExtendedLimitOrderWithRecovery(
|
|
1979
|
+
route.amount.toNumber(),
|
|
1980
|
+
midPrice,
|
|
1981
|
+
OrderSide.SELL,
|
|
1982
|
+
);
|
|
1983
|
+
|
|
1984
|
+
if (!result) {
|
|
1985
|
+
logger.error(
|
|
1986
|
+
`${this._tag}::_executeExtendedIncreaseLever limit order failed`,
|
|
1987
|
+
);
|
|
1988
|
+
return this._failureResult(route);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
await this._emitEvent(ExecutionEventType.SUCCESS, {
|
|
1992
|
+
routeType: RouteType.EXTENDED_INCREASE_LEVER,
|
|
1993
|
+
orderId: result.position_id,
|
|
1994
|
+
amount: result.btc_exposure,
|
|
1995
|
+
executionPrice: result.executionPrice,
|
|
1996
|
+
protocol: 'EXTENDED',
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
return this._successResult(
|
|
2000
|
+
[],
|
|
2001
|
+
Protocols.NONE.name,
|
|
2002
|
+
Protocols.EXTENDED.name,
|
|
2003
|
+
"DEPOSIT",
|
|
2004
|
+
route.amount,
|
|
2005
|
+
CycleType.INVESTMENT,
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* EXTENDED_DECREASE_LEVER: Close portion of SHORT position on Extended
|
|
2011
|
+
* by placing a BUY limit order.
|
|
2012
|
+
*
|
|
2013
|
+
* Uses limit pricing: limitPrice = midPrice * (1 + slippage) to prevent
|
|
2014
|
+
* buying at unfavourable rates.
|
|
2015
|
+
*
|
|
2016
|
+
* Validates price divergence between Extended and Vesu before execution.
|
|
2017
|
+
*/
|
|
2018
|
+
private async _executeExtendedDecreaseLever(
|
|
2019
|
+
route: ExtendedLeverRoute,
|
|
2020
|
+
): Promise<TransactionResult> {
|
|
2021
|
+
const leverage = calculateExtendedLevergae().toString();
|
|
2022
|
+
|
|
2023
|
+
logger.info(
|
|
2024
|
+
`${this._tag}::_executeExtendedDecreaseLever BUY ${route.amount.toNumber()} on ${route.instrument}`,
|
|
2025
|
+
);
|
|
2026
|
+
|
|
2027
|
+
const midPrice = await this._getExtendedMidPrice();
|
|
2028
|
+
if (!midPrice) {
|
|
2029
|
+
logger.error(
|
|
2030
|
+
`${this._tag}::_executeExtendedDecreaseLever could not get extended mid price`,
|
|
2031
|
+
);
|
|
2032
|
+
return this._failureResult(route);
|
|
2033
|
+
}
|
|
2034
|
+
const result = await this._executeExtendedLimitOrderWithRecovery(
|
|
2035
|
+
route.amount.toNumber(),
|
|
2036
|
+
midPrice,
|
|
2037
|
+
OrderSide.BUY,
|
|
2038
|
+
);
|
|
2039
|
+
|
|
2040
|
+
if (!result) {
|
|
2041
|
+
logger.error(
|
|
2042
|
+
`${this._tag}::_executeExtendedDecreaseLever limit order failed`,
|
|
2043
|
+
);
|
|
2044
|
+
return this._failureResult(route);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
await this._emitEvent(ExecutionEventType.SUCCESS, {
|
|
2048
|
+
routeType: RouteType.EXTENDED_DECREASE_LEVER,
|
|
2049
|
+
orderId: result.position_id,
|
|
2050
|
+
amount: result.btc_exposure,
|
|
2051
|
+
executionPrice: result.executionPrice,
|
|
2052
|
+
protocol: 'EXTENDED',
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
return this._successResult(
|
|
2056
|
+
[],
|
|
2057
|
+
Protocols.EXTENDED.name,
|
|
2058
|
+
Protocols.NONE.name,
|
|
2059
|
+
"WITHDRAWAL",
|
|
2060
|
+
route.amount,
|
|
2061
|
+
CycleType.INVESTMENT,
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// ── Crisis leverage routes ──────────────────────────────────────────────
|
|
2066
|
+
|
|
2067
|
+
/**
|
|
2068
|
+
* CRISIS_INCREASE_EXTENDED_MAX_LEVERAGE: Temporarily increase Extended
|
|
2069
|
+
* leverage to the crisis maximum (e.g. 4x) to free margin.
|
|
2070
|
+
*/
|
|
2071
|
+
private async _executeCrisisIncreaseLeverage(): Promise<TransactionResult> {
|
|
2072
|
+
const adapter = this._config.extendedAdapter;
|
|
2073
|
+
const marketName = adapter.config.extendedMarketName;
|
|
2074
|
+
|
|
2075
|
+
logger.info(
|
|
2076
|
+
`${this._tag}::_executeCrisisIncreaseLeverage setting leverage to ${CRISIS_MAX_LEVERAGE}`,
|
|
2077
|
+
);
|
|
2078
|
+
const success = await adapter.setLeverage(CRISIS_MAX_LEVERAGE, marketName);
|
|
2079
|
+
|
|
2080
|
+
if (!success) {
|
|
2081
|
+
logger.error(
|
|
2082
|
+
`${this._tag}::_executeCrisisIncreaseLeverage failed`,
|
|
2083
|
+
);
|
|
2084
|
+
return this._failureResult({
|
|
2085
|
+
type: RouteType.CRISIS_INCREASE_EXTENDED_MAX_LEVERAGE,
|
|
2086
|
+
priority: 0,
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
return this._successResult(
|
|
2091
|
+
[],
|
|
2092
|
+
Protocols.EXTENDED.name,
|
|
2093
|
+
Protocols.EXTENDED.name,
|
|
2094
|
+
"NONE",
|
|
2095
|
+
new Web3Number(0, this._config.usdcToken.decimals),
|
|
2096
|
+
CycleType.INVESTMENT,
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/**
|
|
2101
|
+
* CRISIS_UNDO_EXTENDED_MAX_LEVERAGE: Revert Extended leverage back to
|
|
2102
|
+
* the normal calculated level.
|
|
2103
|
+
*/
|
|
2104
|
+
private async _executeCrisisUndoLeverage(): Promise<TransactionResult> {
|
|
2105
|
+
const adapter = this._config.extendedAdapter;
|
|
2106
|
+
const normalLeverage = calculateExtendedLevergae().toString();
|
|
2107
|
+
const marketName = adapter.config.extendedMarketName;
|
|
2108
|
+
|
|
2109
|
+
logger.info(
|
|
2110
|
+
`${this._tag}::_executeCrisisUndoLeverage reverting leverage to ${normalLeverage}`,
|
|
2111
|
+
);
|
|
2112
|
+
const success = await adapter.setLeverage(normalLeverage, marketName);
|
|
2113
|
+
|
|
2114
|
+
if (!success) {
|
|
2115
|
+
logger.error(
|
|
2116
|
+
`${this._tag}::_executeCrisisUndoLeverage failed`,
|
|
2117
|
+
);
|
|
2118
|
+
return this._failureResult({
|
|
2119
|
+
type: RouteType.CRISIS_UNDO_EXTENDED_MAX_LEVERAGE,
|
|
2120
|
+
priority: 0,
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
return this._successResult(
|
|
2125
|
+
[],
|
|
2126
|
+
Protocols.EXTENDED.name,
|
|
2127
|
+
Protocols.EXTENDED.name,
|
|
2128
|
+
"NONE",
|
|
2129
|
+
new Web3Number(0, this._config.usdcToken.decimals),
|
|
2130
|
+
CycleType.INVESTMENT,
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2135
|
+
// Private — pending deposit helpers
|
|
2136
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2137
|
+
|
|
2138
|
+
/**
|
|
2139
|
+
* Adjusts a deposit-to-Extended amount by the remaining pending deposit
|
|
2140
|
+
* budget. If pending deposit > 0, those funds are already in transit and
|
|
2141
|
+
* shouldn't be re-sent.
|
|
2142
|
+
*
|
|
2143
|
+
* Mutates `_pendingDepositRemaining` as the budget is consumed.
|
|
2144
|
+
*/
|
|
2145
|
+
private _consumePendingDeposit(amount: Web3Number): Web3Number {
|
|
2146
|
+
if (this._pendingDepositRemaining <= 0) return amount;
|
|
2147
|
+
|
|
2148
|
+
const raw = amount.toNumber();
|
|
2149
|
+
const adjusted = raw - this._pendingDepositRemaining;
|
|
2150
|
+
|
|
2151
|
+
if (adjusted <= 0) {
|
|
2152
|
+
this._pendingDepositRemaining -= raw;
|
|
2153
|
+
logger.info(
|
|
2154
|
+
`${this._tag}::_consumePendingDeposit fully covered by pending (remaining: ${this._pendingDepositRemaining})`,
|
|
2155
|
+
);
|
|
2156
|
+
return new Web3Number(0, this._config.usdcToken.decimals);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
this._pendingDepositRemaining = 0;
|
|
2160
|
+
return new Web3Number(
|
|
2161
|
+
adjusted.toFixed(this._config.usdcToken.decimals),
|
|
2162
|
+
this._config.usdcToken.decimals,
|
|
2163
|
+
);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2167
|
+
// Private — AVNU buffer helper
|
|
2168
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* Applies a 0.1% buffer (AVNU_BUFFER_FACTOR) to an amount.
|
|
2172
|
+
* This ensures the downstream consumer (Vesu deposit or AVNU swap)
|
|
2173
|
+
* always has sufficient tokens despite minor slippage.
|
|
2174
|
+
*/
|
|
2175
|
+
private _applyAvnuBuffer(amount: Web3Number, decimals: number): Web3Number {
|
|
2176
|
+
return new Web3Number(
|
|
2177
|
+
amount.multipliedBy(AVNU_BUFFER_FACTOR).toFixed(decimals),
|
|
2178
|
+
decimals,
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2183
|
+
// Private — result helpers
|
|
2184
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Creates a TransactionResult for a batch of on-chain calls.
|
|
2188
|
+
* The caller is expected to send all calls in a single multicall transaction.
|
|
2189
|
+
*/
|
|
2190
|
+
private _onChainBatchResult(calls: Call[]): TransactionResult {
|
|
2191
|
+
StarknetCallParser.logCallsSummary(
|
|
2192
|
+
`${this._tag}::_onChainBatchResult decoded calls`,
|
|
2193
|
+
calls,
|
|
2194
|
+
{
|
|
2195
|
+
tokenSymbols: this._tokenSymbols,
|
|
2196
|
+
tokenDecimals: this._tokenDecimals,
|
|
2197
|
+
poolNames: this._poolNames,
|
|
2198
|
+
},
|
|
2199
|
+
(message) => logger.debug(message),
|
|
2200
|
+
);
|
|
2201
|
+
return {
|
|
2202
|
+
calls,
|
|
2203
|
+
status: true,
|
|
2204
|
+
transactionMetadata: {
|
|
2205
|
+
protocolFrom: "BATCH",
|
|
2206
|
+
protocolTo: "BATCH",
|
|
2207
|
+
transactionType: "NONE",
|
|
2208
|
+
usdAmount: "0",
|
|
2209
|
+
status: "PENDING",
|
|
2210
|
+
cycleType: CycleType.INVESTMENT,
|
|
2211
|
+
},
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
private _successResult(
|
|
2216
|
+
calls: Call[],
|
|
2217
|
+
from: string,
|
|
2218
|
+
to: string,
|
|
2219
|
+
txnType: "DEPOSIT" | "WITHDRAWAL" | "NONE",
|
|
2220
|
+
amount: Web3Number,
|
|
2221
|
+
cycleType: CycleType,
|
|
2222
|
+
): TransactionResult {
|
|
2223
|
+
return {
|
|
2224
|
+
calls,
|
|
2225
|
+
status: true,
|
|
2226
|
+
transactionMetadata: {
|
|
2227
|
+
protocolFrom: from,
|
|
2228
|
+
protocolTo: to,
|
|
2229
|
+
transactionType: txnType,
|
|
2230
|
+
usdAmount: amount.abs().toFixed(),
|
|
2231
|
+
status: "PENDING",
|
|
2232
|
+
cycleType,
|
|
2233
|
+
},
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
private _failureResult(route: ExecutionRoute): TransactionResult {
|
|
2238
|
+
return {
|
|
2239
|
+
calls: [],
|
|
2240
|
+
status: false,
|
|
2241
|
+
transactionMetadata: {
|
|
2242
|
+
protocolFrom: "",
|
|
2243
|
+
protocolTo: "",
|
|
2244
|
+
transactionType: "NONE",
|
|
2245
|
+
usdAmount: "0",
|
|
2246
|
+
status: "FAILED",
|
|
2247
|
+
cycleType: CycleType.INVESTMENT,
|
|
2248
|
+
},
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
}
|