@strkfarm/sdk 2.0.0-dev.26 → 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.
Files changed (70) hide show
  1. package/dist/cli.js +190 -36
  2. package/dist/cli.mjs +188 -34
  3. package/dist/index.browser.global.js +79130 -49354
  4. package/dist/index.browser.mjs +18039 -11431
  5. package/dist/index.d.ts +2869 -898
  6. package/dist/index.js +19036 -12207
  7. package/dist/index.mjs +18942 -12158
  8. package/package.json +1 -1
  9. package/src/data/avnu.abi.json +840 -0
  10. package/src/data/ekubo-price-fethcer.abi.json +265 -0
  11. package/src/dataTypes/_bignumber.ts +13 -4
  12. package/src/dataTypes/index.ts +3 -2
  13. package/src/dataTypes/mynumber.ts +141 -0
  14. package/src/global.ts +76 -41
  15. package/src/index.browser.ts +2 -1
  16. package/src/interfaces/common.tsx +167 -2
  17. package/src/modules/ExtendedWrapperSDk/types.ts +26 -4
  18. package/src/modules/ExtendedWrapperSDk/wrapper.ts +110 -67
  19. package/src/modules/apollo-client-config.ts +28 -0
  20. package/src/modules/avnu.ts +4 -4
  21. package/src/modules/ekubo-pricer.ts +79 -0
  22. package/src/modules/ekubo-quoter.ts +46 -30
  23. package/src/modules/erc20.ts +17 -0
  24. package/src/modules/harvests.ts +43 -29
  25. package/src/modules/pragma.ts +23 -8
  26. package/src/modules/pricer-from-api.ts +156 -15
  27. package/src/modules/pricer-lst.ts +1 -1
  28. package/src/modules/pricer.ts +40 -4
  29. package/src/modules/pricerBase.ts +2 -1
  30. package/src/node/deployer.ts +36 -1
  31. package/src/node/pricer-redis.ts +2 -1
  32. package/src/strategies/base-strategy.ts +78 -10
  33. package/src/strategies/ekubo-cl-vault.tsx +906 -347
  34. package/src/strategies/factory.ts +159 -0
  35. package/src/strategies/index.ts +6 -1
  36. package/src/strategies/registry.ts +239 -0
  37. package/src/strategies/sensei.ts +335 -7
  38. package/src/strategies/svk-strategy.ts +97 -27
  39. package/src/strategies/types.ts +4 -0
  40. package/src/strategies/universal-adapters/adapter-utils.ts +2 -1
  41. package/src/strategies/universal-adapters/avnu-adapter.ts +177 -268
  42. package/src/strategies/universal-adapters/baseAdapter.ts +263 -251
  43. package/src/strategies/universal-adapters/common-adapter.ts +206 -203
  44. package/src/strategies/universal-adapters/extended-adapter.ts +155 -336
  45. package/src/strategies/universal-adapters/index.ts +9 -8
  46. package/src/strategies/universal-adapters/token-transfer-adapter.ts +200 -0
  47. package/src/strategies/universal-adapters/usdc<>usdce-adapter.ts +200 -0
  48. package/src/strategies/universal-adapters/vesu-adapter.ts +110 -75
  49. package/src/strategies/universal-adapters/vesu-modify-position-adapter.ts +476 -0
  50. package/src/strategies/universal-adapters/vesu-multiply-adapter.ts +762 -844
  51. package/src/strategies/universal-adapters/vesu-position-common.ts +251 -0
  52. package/src/strategies/universal-adapters/vesu-supply-only-adapter.ts +18 -3
  53. package/src/strategies/universal-lst-muliplier-strategy.tsx +396 -204
  54. package/src/strategies/universal-strategy.tsx +1426 -1178
  55. package/src/strategies/vesu-extended-strategy/services/executionService.ts +2251 -0
  56. package/src/strategies/vesu-extended-strategy/services/extended-vesu-state-manager.ts +2941 -0
  57. package/src/strategies/vesu-extended-strategy/services/operationService.ts +12 -1
  58. package/src/strategies/vesu-extended-strategy/types/transaction-metadata.ts +52 -0
  59. package/src/strategies/vesu-extended-strategy/utils/config.runtime.ts +1 -0
  60. package/src/strategies/vesu-extended-strategy/utils/constants.ts +3 -1
  61. package/src/strategies/vesu-extended-strategy/utils/helper.ts +158 -124
  62. package/src/strategies/vesu-extended-strategy/vesu-extended-strategy.tsx +377 -1781
  63. package/src/strategies/vesu-rebalance.tsx +255 -152
  64. package/src/utils/health-factor-math.ts +4 -1
  65. package/src/utils/index.ts +2 -1
  66. package/src/utils/logger.browser.ts +22 -4
  67. package/src/utils/logger.node.ts +259 -24
  68. package/src/utils/starknet-call-parser.ts +1036 -0
  69. package/src/utils/strategy-utils.ts +61 -0
  70. 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
+ }