@strkfarm/sdk 2.0.0-dev.28 → 2.0.0-dev.29

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.
@@ -12,6 +12,8 @@ import {
12
12
  calculateExtendedLevergae,
13
13
  calculateVesuLeverage,
14
14
  } from "../utils/helper";
15
+ import { VesuConfig } from "../utils/config.runtime";
16
+ import { rebalance, type RebalanceDeltas } from "./ltv-imbalance-rebalance-math";
15
17
 
16
18
  // ─── State types ───────────────────────────────────────────────────────────────
17
19
 
@@ -58,6 +60,26 @@ export interface ExtendedBalanceState {
58
60
  pendingDeposit: Web3Number;
59
61
  }
60
62
 
63
+
64
+ type Inputs = {
65
+ extAvlWithdraw: number;
66
+ extUpnl: number;
67
+ vaUsd: number;
68
+ walletUsd: number;
69
+ vesuBorrowCapacity: number;
70
+ vesuLeverage: number;
71
+ extendedLeverage: number;
72
+ };
73
+
74
+ type Deltas = {
75
+ dExtAvlWithdraw: number;
76
+ dExtUpnl: number;
77
+ dVaUsd: number;
78
+ dWalletUsd: number;
79
+ dVesuBorrowCapacity: number;
80
+ isExtendedToVesu: boolean;
81
+ };
82
+
61
83
  /**
62
84
  * Generic token balance with USD valuation.
63
85
  */
@@ -99,9 +121,9 @@ export interface VesuPoolDelta {
99
121
  * Enumerates all possible fund-routing paths used during execution.
100
122
  */
101
123
  export enum RouteType {
102
- /** P1: Deposit USDC.e from operator wallet directly to Extended exchange */
124
+ /** Deposit USDC from operator wallet directly to Extended exchange */
103
125
  WALLET_TO_EXTENDED = 'WALLET_TO_EXTENDED',
104
- /** P2: USDC from vault allocator → swap to USDC.e deposit to Extended */
126
+ /** USDC from vault allocator → deposit to Extended (via manager) */
105
127
  VA_TO_EXTENDED = 'VA_TO_EXTENDED',
106
128
  /** Withdraw from Extended exchange → operator wallet */
107
129
  EXTENDED_TO_WALLET = 'EXTENDED_TO_WALLET',
@@ -117,7 +139,7 @@ export enum RouteType {
117
139
  VESU_BORROW = 'VESU_BORROW',
118
140
  /** Repay USDC debt to Vesu (debtDelta < 0) */
119
141
  VESU_REPAY = 'VESU_REPAY',
120
- /** Transfer USDC.e from operator wallet to vault allocator */
142
+ /** Transfer USDC from operator wallet to vault allocator */
121
143
  WALLET_TO_VA = 'WALLET_TO_VA',
122
144
  /** Realize PnL on Extended exchange */
123
145
  REALISE_PNL = 'REALISE_PNL',
@@ -284,11 +306,12 @@ export enum CaseCategory {
284
306
  */
285
307
  export enum CaseId {
286
308
  // LTV Rebalance
287
- LTV_VESU_LOW_TO_EXTENDED = 'LTV_VESU_LOW_TO_EXTENDED',
288
- LTV_EXTENDED_PROFITABLE_AVAILABLE = 'LTV_EXTENDED_PROFITABLE_AVAILABLE',
289
- LTV_EXTENDED_PROFITABLE_REALIZE = 'LTV_EXTENDED_PROFITABLE_REALIZE',
290
- LTV_VESU_HIGH_USE_VA_OR_WALLET = 'LTV_VESU_HIGH_USE_VA_OR_WALLET',
291
- LTV_EXTENDED_HIGH_USE_VA_OR_WALLET = 'LTV_EXTENDED_HIGH_USE_VA_OR_WALLET',
309
+ MANAGE_LTV = 'MANAGE_LTV',
310
+ /** @deprecated use MANAGE_LTV */ LTV_VESU_LOW_TO_EXTENDED = 'LTV_VESU_LOW_TO_EXTENDED',
311
+ /** @deprecated use MANAGE_LTV */ LTV_EXTENDED_PROFITABLE_AVAILABLE = 'LTV_EXTENDED_PROFITABLE_AVAILABLE',
312
+ /** @deprecated use MANAGE_LTV */ LTV_EXTENDED_PROFITABLE_REALIZE = 'LTV_EXTENDED_PROFITABLE_REALIZE',
313
+ /** @deprecated use MANAGE_LTV */ LTV_VESU_HIGH_USE_VA_OR_WALLET = 'LTV_VESU_HIGH_USE_VA_OR_WALLET',
314
+ /** @deprecated use MANAGE_LTV */ LTV_EXTENDED_HIGH_USE_VA_OR_WALLET = 'LTV_EXTENDED_HIGH_USE_VA_OR_WALLET',
292
315
 
293
316
  // New Deposits / Excess Funds
294
317
  DEPOSIT_FRESH_VAULT = 'DEPOSIT_FRESH_VAULT',
@@ -324,7 +347,7 @@ export interface SolveCase {
324
347
  const CASE_THRESHOLD_USD = 5;
325
348
  const CASE_MIN_BRIDING_USD = 10;
326
349
  /** Decimal places for rounding collateral / exposure deltas in token terms (e.g. BTC) */
327
- const COLLATERAL_PRECISION = 4;
350
+ export const COLLATERAL_PRECISION = 4;
328
351
 
329
352
  /** Safely create a Web3Number from a float, avoiding >15 significant digit errors */
330
353
  function safeUsdcWeb3Number(value: number): Web3Number {
@@ -346,19 +369,43 @@ export interface SolveCaseEntry {
346
369
  * Used to filter the global route list into per-case route subsets.
347
370
  */
348
371
  export const CASE_ROUTE_TYPES: Record<CaseId, RouteType[]> = {
349
- // LTV Rebalance — Vesu side
350
- [CaseId.LTV_VESU_HIGH_USE_VA_OR_WALLET]: [RouteType.WALLET_TO_VA, RouteType.VESU_REPAY], // use wallet to va if wallet has and va doesnt have enough
351
- [CaseId.LTV_EXTENDED_PROFITABLE_AVAILABLE]: [RouteType.EXTENDED_TO_WALLET, RouteType.RETURN_TO_WAIT, RouteType.WALLET_TO_VA, RouteType.VESU_REPAY],
352
- [CaseId.LTV_EXTENDED_PROFITABLE_REALIZE]: [RouteType.REALISE_PNL, RouteType.EXTENDED_TO_WALLET, RouteType.RETURN_TO_WAIT, RouteType.WALLET_TO_VA, RouteType.VESU_REPAY],
353
- // LTV Rebalance — Extended side
354
- [CaseId.LTV_EXTENDED_HIGH_USE_VA_OR_WALLET]: [RouteType.VA_TO_EXTENDED, RouteType.WALLET_TO_EXTENDED],
355
- [CaseId.LTV_VESU_LOW_TO_EXTENDED]: [RouteType.VESU_BORROW, RouteType.VA_TO_EXTENDED],
372
+ // LTV Rebalance — unified
373
+ [CaseId.MANAGE_LTV]: [
374
+ RouteType.REALISE_PNL, RouteType.EXTENDED_TO_WALLET, RouteType.RETURN_TO_WAIT,
375
+ RouteType.WALLET_TO_VA, RouteType.VESU_BORROW, RouteType.VESU_REPAY, RouteType.VA_TO_EXTENDED,
376
+ RouteType.VESU_MULTIPLY_DECREASE_LEVER, RouteType.VESU_MULTIPLY_INCREASE_LEVER,
377
+ RouteType.AVNU_DEPOSIT_SWAP,
378
+ RouteType.EXTENDED_DECREASE_LEVER, RouteType.EXTENDED_INCREASE_LEVER,
379
+ // Second-phase VA / Extended funding after lever routes (same types may repeat).
380
+ RouteType.RETURN_TO_WAIT, RouteType.WALLET_TO_VA, RouteType.VA_TO_EXTENDED,
381
+ ],
382
+ /** @deprecated */ [CaseId.LTV_VESU_HIGH_USE_VA_OR_WALLET]: [RouteType.WALLET_TO_VA, RouteType.VESU_REPAY],
383
+ /** @deprecated */ [CaseId.LTV_EXTENDED_PROFITABLE_AVAILABLE]: [RouteType.EXTENDED_TO_WALLET, RouteType.RETURN_TO_WAIT, RouteType.WALLET_TO_VA, RouteType.VESU_REPAY],
384
+ /** @deprecated */ [CaseId.LTV_EXTENDED_PROFITABLE_REALIZE]: [RouteType.REALISE_PNL, RouteType.EXTENDED_TO_WALLET, RouteType.RETURN_TO_WAIT, RouteType.WALLET_TO_VA, RouteType.VESU_REPAY],
385
+ /** @deprecated */ [CaseId.LTV_EXTENDED_HIGH_USE_VA_OR_WALLET]: [RouteType.VA_TO_EXTENDED, RouteType.WALLET_TO_EXTENDED],
386
+ /** @deprecated */ [CaseId.LTV_VESU_LOW_TO_EXTENDED]: [RouteType.VESU_BORROW, RouteType.VA_TO_EXTENDED],
356
387
 
357
388
  // New Deposits
358
389
  // @dev, when handling routes, after VA_TO_EXTENDED and/or WALLET_TO_EXTENDED, return. bcz, funds take time to reach extended. Anyways, in next cycle, these funds will be computed to increase lever
359
390
  // Sequence: fund-movement transfers first (WALLET_TO_EXTENDED, VA_TO_EXTENDED, WALLET_TO_VA, EXTENDED_TO_WALLET),
360
391
  // then RETURN_TO_WAIT, then optional second WALLET_TO_VA + RETURN_TO_WAIT, then lever routes.
361
- [CaseId.DEPOSIT_FRESH_VAULT]: [RouteType.WALLET_TO_EXTENDED, RouteType.VA_TO_EXTENDED, RouteType.WALLET_TO_VA, RouteType.EXTENDED_TO_WALLET, RouteType.RETURN_TO_WAIT, RouteType.WALLET_TO_VA, RouteType.AVNU_DEPOSIT_SWAP, RouteType.VESU_MULTIPLY_INCREASE_LEVER, RouteType.EXTENDED_INCREASE_LEVER],
392
+ [CaseId.DEPOSIT_FRESH_VAULT]: [
393
+ // May repeat after MANAGE_LTV (VA top-up → Extended → wait → levers).
394
+ RouteType.WALLET_TO_VA,
395
+ RouteType.VA_TO_EXTENDED,
396
+ RouteType.RETURN_TO_WAIT,
397
+ RouteType.VA_TO_EXTENDED,
398
+ RouteType.VESU_BORROW,
399
+ RouteType.WALLET_TO_EXTENDED,
400
+ RouteType.VA_TO_EXTENDED,
401
+ RouteType.WALLET_TO_VA,
402
+ RouteType.EXTENDED_TO_WALLET,
403
+ RouteType.RETURN_TO_WAIT,
404
+ RouteType.WALLET_TO_VA,
405
+ RouteType.AVNU_DEPOSIT_SWAP,
406
+ RouteType.VESU_MULTIPLY_INCREASE_LEVER,
407
+ RouteType.EXTENDED_INCREASE_LEVER,
408
+ ],
362
409
  [CaseId.DEPOSIT_EXTENDED_AVAILABLE]: [RouteType.EXTENDED_TO_WALLET, RouteType.RETURN_TO_WAIT, RouteType.WALLET_TO_VA, RouteType.AVNU_DEPOSIT_SWAP, RouteType.VESU_MULTIPLY_INCREASE_LEVER, RouteType.EXTENDED_INCREASE_LEVER],
363
410
  [CaseId.DEPOSIT_VESU_BORROW_CAPACITY]: [RouteType.VESU_BORROW, RouteType.VA_TO_EXTENDED, RouteType.RETURN_TO_WAIT, RouteType.AVNU_DEPOSIT_SWAP, RouteType.VESU_MULTIPLY_INCREASE_LEVER, RouteType.EXTENDED_INCREASE_LEVER],
364
411
  [CaseId.DEPOSIT_COMBINATION]: [], // exroutes to be computed on the fly based on above sub routes and where deposit is available
@@ -393,6 +440,17 @@ const IMBALANCE_THRESHOLD_FRACTION = 0.0002; // 0.02%
393
440
  * Each entry maps a CaseId to its metadata and descriptive steps.
394
441
  */
395
442
  const CASE_DEFINITIONS: Record<CaseId, SolveCase> = {
443
+ [CaseId.MANAGE_LTV]: {
444
+ id: CaseId.MANAGE_LTV,
445
+ category: CaseCategory.LTV_REBALANCE,
446
+ title: 'LTV Rebalance: Unified Vesu repay + Extended margin management',
447
+ description: 'Manages both Vesu high-LTV repayment and Extended low-margin funding in a single pass.',
448
+ steps: [
449
+ 'Compute vesu repay needed and extended margin needed',
450
+ 'Allocate funds: VA > Wallet > ExtAvl > ExtUpnl for Vesu; Wallet > VA > Borrow for Extended',
451
+ 'Build combined transfer and repay/margin routes',
452
+ ],
453
+ },
396
454
  [CaseId.LTV_VESU_LOW_TO_EXTENDED]: {
397
455
  id: CaseId.LTV_VESU_LOW_TO_EXTENDED,
398
456
  category: CaseCategory.LTV_REBALANCE,
@@ -619,9 +677,13 @@ export interface StateManagerConfig {
619
677
  extendedAdapter: ExtendedAdapter;
620
678
  vaultAllocator: ContractAddr;
621
679
  walletAddress: string;
680
+ /** Strategy / vault token — idle balance in the vault allocator is denominated in this asset */
622
681
  assetToken: TokenInfo;
623
- /** USDC.e token for wallet balance checks during route computation */
624
- usdceToken: TokenInfo;
682
+ /**
683
+ * Native USDC (Starknet) — operator wallet stablecoin balance is always read for this token,
684
+ * independent of {@link assetToken} (e.g. when the strategy vault uses another asset).
685
+ */
686
+ usdcToken: TokenInfo;
625
687
  /** Collateral token (e.g. WBTC) for wallet balance checks */
626
688
  collateralToken: TokenInfo;
627
689
  limitBalanceBufferFactor: number;
@@ -672,231 +734,734 @@ export function routeSummary(r: ExecutionRoute): string {
672
734
  /**
673
735
  * Single source of truth for all mutable state during a solve() call.
674
736
  *
675
- * Holds both the refreshed on-chain/off-chain state snapshots AND the
676
- * budget-tracking values consumed by sub-classifiers. Spend methods
677
- * automatically keep `totalUnused` in sync. State-mutation methods
678
- * (`applyVesuDelta`, `applyExtendedExposureDelta`, `applyExtendedBalanceChange`)
679
- * update the underlying snapshots so that downstream classifiers always
680
- * see the most up-to-date picture.
737
+ * Stores **raw** snapshots from refresh (no safety buffer applied in fields).
738
+ * Buffer {@link limitBalanceBufferFactor} is applied only in **getters** used
739
+ * during solve (caps, diagnostics). **Spend / add** methods mutate balances
740
+ * in **raw** USD only (no buffer on debits or credits).
741
+ *
742
+ * **Extended deposits** ({@link SolveBudget.addToExtAvailTrade}): while account equity is
743
+ * below required margin (Σ position value ÷ Extended leverage), incoming USDC is credited
744
+ * only to **balance** and **equity**. Only the excess is credited to **availableForWithdrawal**
745
+ * and **availableForTrade**, matching “margin first, then free collateral”.
681
746
  */
682
747
  export class SolveBudget {
683
- // ── Refreshed state (mutable during solve) ──────────────────────────
684
- unusedBalance: TokenBalance[];
685
- walletBalance: TokenBalance | null;
686
- vaultBalance: TokenBalance | null;
687
- extendedPositions: ExtendedPositionState[];
688
- extendedBalance: ExtendedBalanceState | null;
689
- vesuPoolStates: VesuPoolState[];
690
- vesuPerPoolDebtDeltasToBorrow: Web3Number[];
691
- shouldVesuRebalance: boolean[]; // should be same length as vesuPerPoolDebtDeltasToBorrow
692
-
693
- // ── Budget tracking (populated by initBudget) ──────────────────────
694
- private _vaUsd: number = 0;
695
- private _walletUsd: number = 0;
696
- private _extAvailWithdraw: number = 0;
697
- private _extAvailUpnl: number = 0;
698
- private _extAvailTrade: number = 0;
699
- private _extPendingDeposit: number = 0;
700
- private _vesuBorrowCapacity: number = 0;
701
- private _totalUnused: number = 0;
748
+ // ── Refreshed state (mutable during solve, raw on-chain / API values)
749
+ private unusedBalance: TokenBalance[];
750
+ private walletBalance: TokenBalance | null;
751
+ /** Idle {@link StateManagerConfig.assetToken} in the vault allocator */
752
+ private vaultAssetBalance: TokenBalance | null;
753
+ /**
754
+ * Idle {@link StateManagerConfig.usdcToken} in the vault allocator.
755
+ * When asset and USDC are the same token, this is null (all VA stablecoin is in {@link vaultAssetBalance} only).
756
+ */
757
+ private vaultUsdcBalance: TokenBalance | null;
758
+ private extendedPositions: ExtendedPositionState[];
759
+ private extendedBalance: ExtendedBalanceState | null;
760
+ private vesuPoolStates: VesuPoolState[];
761
+ private vesuPerPoolDebtDeltasToBorrow: Web3Number[];
762
+ private shouldVesuRebalance: boolean[]; // should be same length as vesuPerPoolDebtDeltasToBorrow
763
+
764
+ readonly assetToken: TokenInfo;
765
+ readonly usdcToken: TokenInfo;
702
766
 
703
767
  constructor(state: {
704
- limitBalanceBufferFactor: number;
768
+ assetToken: TokenInfo;
769
+ usdcToken: TokenInfo;
705
770
  unusedBalance: TokenBalance[];
706
771
  walletBalance: TokenBalance | null;
707
- vaultBalance: TokenBalance | null;
772
+ vaultAssetBalance: TokenBalance | null;
773
+ vaultUsdcBalance: TokenBalance | null;
708
774
  extendedPositions: ExtendedPositionState[];
709
775
  extendedBalance: ExtendedBalanceState | null;
710
776
  vesuPoolStates: VesuPoolState[];
711
777
  }) {
712
- const buffer = state.limitBalanceBufferFactor;
713
- this.unusedBalance = state.unusedBalance.map((item) => {
714
- return {
715
- ...item,
716
- amount: item.amount.multipliedBy(1 - buffer),
717
- usdValue: item.usdValue * (1 - buffer),
718
- };
778
+ this.assetToken = state.assetToken;
779
+ this.usdcToken = state.usdcToken;
780
+
781
+ const cloneTb = (b: TokenBalance): TokenBalance => ({
782
+ token: b.token,
783
+ amount: new Web3Number(b.amount.toFixed(b.token.decimals), b.token.decimals),
784
+ usdValue: b.usdValue,
719
785
  });
720
- this.walletBalance = state.walletBalance ? {
721
- ...state.walletBalance,
722
- amount: state.walletBalance.amount.multipliedBy(1 - buffer),
723
- usdValue: state.walletBalance.usdValue * (1 - buffer),
724
- } : null;
725
- this.vaultBalance = state.vaultBalance ? {
726
- ...state.vaultBalance,
727
- amount: state.vaultBalance.amount.multipliedBy(1 - buffer),
728
- usdValue: state.vaultBalance.usdValue * (1 - buffer),
729
- } : null;
730
- this.extendedPositions = state.extendedPositions;
731
- this.extendedBalance = state.extendedBalance ? {
732
- ...state.extendedBalance,
733
- availableForTrade: state.extendedBalance.availableForTrade.multipliedBy(1 - buffer),
734
- availableForWithdrawal: state.extendedBalance.availableForWithdrawal.multipliedBy(1 - buffer),
735
- unrealisedPnl: state.extendedBalance.unrealisedPnl.multipliedBy(1 - buffer),
736
- balance: state.extendedBalance.balance.multipliedBy(1 - buffer),
737
- } : null;
738
- this.vesuPoolStates = state.vesuPoolStates;
786
+
787
+ this.unusedBalance = state.unusedBalance.map((item) => cloneTb(item));
788
+ this.walletBalance = state.walletBalance ? cloneTb(state.walletBalance) : null;
789
+ this.vaultAssetBalance = state.vaultAssetBalance ? cloneTb(state.vaultAssetBalance) : null;
790
+ this.vaultUsdcBalance = state.vaultUsdcBalance ? cloneTb(state.vaultUsdcBalance) : null;
791
+
792
+ // Deep-clone mutable Extended / Vesu snapshots so route-building dry-runs (e.g. _classifyLTV)
793
+ // cannot mutate shared objects when callers reuse the same fixture for multiple budgets.
794
+ this.extendedPositions = state.extendedPositions.map((p) => ({
795
+ ...p,
796
+ size: new Web3Number(p.size.toFixed(8), 8),
797
+ valueUsd: new Web3Number(p.valueUsd.toFixed(USDC_TOKEN_DECIMALS), USDC_TOKEN_DECIMALS),
798
+ }));
799
+ this.extendedBalance = state.extendedBalance
800
+ ? {
801
+ equity: new Web3Number(
802
+ state.extendedBalance.equity.toFixed(USDC_TOKEN_DECIMALS),
803
+ USDC_TOKEN_DECIMALS,
804
+ ),
805
+ availableForTrade: new Web3Number(
806
+ state.extendedBalance.availableForTrade.toFixed(USDC_TOKEN_DECIMALS),
807
+ USDC_TOKEN_DECIMALS,
808
+ ),
809
+ availableForWithdrawal: new Web3Number(
810
+ state.extendedBalance.availableForWithdrawal.toFixed(USDC_TOKEN_DECIMALS),
811
+ USDC_TOKEN_DECIMALS,
812
+ ),
813
+ unrealisedPnl: new Web3Number(
814
+ state.extendedBalance.unrealisedPnl.toFixed(USDC_TOKEN_DECIMALS),
815
+ USDC_TOKEN_DECIMALS,
816
+ ),
817
+ balance: new Web3Number(
818
+ state.extendedBalance.balance.toFixed(USDC_TOKEN_DECIMALS),
819
+ USDC_TOKEN_DECIMALS,
820
+ ),
821
+ pendingDeposit: new Web3Number(
822
+ state.extendedBalance.pendingDeposit.toFixed(USDC_TOKEN_DECIMALS),
823
+ USDC_TOKEN_DECIMALS,
824
+ ),
825
+ }
826
+ : null;
827
+ this.vesuPoolStates = state.vesuPoolStates.map((p) => ({
828
+ ...p,
829
+ collateralAmount: new Web3Number(
830
+ p.collateralAmount.toFixed(p.collateralToken.decimals),
831
+ p.collateralToken.decimals,
832
+ ),
833
+ debtAmount: new Web3Number(
834
+ p.debtAmount.toFixed(p.debtToken.decimals),
835
+ p.debtToken.decimals,
836
+ ),
837
+ }));
739
838
  const vesuPerPoolDebtDeltasToBorrow = this._computeperPoolDebtDeltasToBorrow();
740
839
  assert(vesuPerPoolDebtDeltasToBorrow.length === this.vesuPoolStates.length, 'vesuPerPoolDebtDeltasToBorrow length must match vesuPoolStates length');
741
840
  this.vesuPerPoolDebtDeltasToBorrow = vesuPerPoolDebtDeltasToBorrow.map((item) => item.deltaDebt);
742
841
  this.shouldVesuRebalance = vesuPerPoolDebtDeltasToBorrow.map((item) => item.shouldRebalance);
743
842
  }
744
843
 
844
+ /** `1 - limitBalanceBufferFactor` — multiplier applied to raw notionals for “usable” USD. */
845
+ private _usableFraction(): number {
846
+ return 1;
847
+ }
848
+
745
849
  /**
746
- * Initialise budget-tracking values from the current state snapshot.
747
- * Must be called after state is populated and debt deltas are computed.
748
- *
749
- * Accounts for pendingDeposit:
750
- * - pendingDeposit > 0: funds in transit TO Extended → increase effective Extended available-for-trade
751
- * - pendingDeposit < 0: funds in transit FROM Extended → increase effective wallet balance
850
+ * Raw USD notional for a token row. USDC (and configured {@link usdcToken}) uses 1:1 from amount;
851
+ * non-stable assets (e.g. WBTC in VA) use {@link TokenBalance.usdValue} from the pricer at refresh.
852
+ */
853
+ private _rawTokenUsd(tb: TokenBalance | null | undefined): number {
854
+ if (!tb) return 0;
855
+ if (this.usdcToken.address.eq(tb.token.address)) {
856
+ return Number(tb.amount.toFixed(tb.token.decimals));
857
+ }
858
+ return tb.usdValue;
859
+ }
860
+
861
+ /** Apply safety buffer to a raw USD scalar. */
862
+ bufferedUsd(rawUsd: number): number {
863
+ return rawUsd * this._usableFraction();
864
+ }
865
+
866
+ /** Convert a buffered “usable” USD amount to raw nominal USD (inverse of {@link bufferedUsd}). */
867
+ rawUsdFromBuffered(bufferedUsd: number): number {
868
+ const bf = this._usableFraction();
869
+ assert(bf > 0, 'SolveBudget::rawUsdFromBuffered usable fraction must be positive');
870
+ return bufferedUsd / bf;
871
+ }
872
+
873
+ /** Buffered USD notional for one token balance row. */
874
+ bufferedTokenUsd(tb: TokenBalance | null | undefined): number {
875
+ return this.bufferedUsd(this._rawTokenUsd(tb));
876
+ }
877
+
878
+ logStateSummary() {
879
+ console.log("===== state summary =====");
880
+ const aggregatedData = {
881
+ unusedBalances: this.unusedBalance.map((b) => ({
882
+ token: b.token.symbol,
883
+ amount: b.amount.toNumber(),
884
+ })),
885
+ walletBalance: this.walletBalance
886
+ ? {
887
+ token: this.walletBalance.token.symbol,
888
+ amount: this.walletBalance.amount.toNumber(),
889
+ }
890
+ : undefined,
891
+ vaultAssetBalance: this.vaultAssetBalance
892
+ ? {
893
+ token: this.vaultAssetBalance.token.symbol,
894
+ amount: this.vaultAssetBalance.amount.toNumber(),
895
+ }
896
+ : undefined,
897
+ vaultUsdcBalance: this.vaultUsdcBalance
898
+ ? {
899
+ token: this.vaultUsdcBalance.token.symbol,
900
+ amount: this.vaultUsdcBalance.amount.toNumber(),
901
+ }
902
+ : undefined,
903
+ vesuPoolStates: this.vesuPoolStates.map((p) => ({
904
+ poolId: p.poolId,
905
+ collateralAmount: p.collateralAmount.toNumber(),
906
+ debtAmount: p.debtAmount.toNumber(),
907
+ })),
908
+ vesuBorrowCapacity: this.vesuBorrowCapacity,
909
+ vesuRebalance: this.shouldVesuRebalance,
910
+ vesuPerPoolDebtDeltasToBorrow: this.vesuPerPoolDebtDeltasToBorrow.map((d) => d.toNumber()),
911
+ extendedBalance: this.extendedBalance?.balance.toNumber(),
912
+ extendedEquity: this.extendedBalance?.equity.toNumber(),
913
+ extendedAvailableForTrade: this.extendedBalance?.availableForTrade.toNumber(),
914
+ extendedAvailableForWithdrawal: this.extendedBalance?.availableForWithdrawal.toNumber(),
915
+ extendedUnrealisedPnl: this.extendedBalance?.unrealisedPnl.toNumber(),
916
+ extendedPendingDeposit: this.extendedBalance?.pendingDeposit.toNumber(),
917
+ extendedPositions: this.extendedPositions.map((p) => ({
918
+ instrument: p.instrument,
919
+ size: p.size.toNumber(),
920
+ valueUsd: p.valueUsd.toNumber(),
921
+ })),
922
+ }
923
+ // unused balances
924
+ console.log(
925
+ "unused balances",
926
+ aggregatedData.unusedBalances
927
+ .map((b) => `${b.token}=${b.amount}`)
928
+ .join(", "),
929
+ );
930
+ console.log(
931
+ "wallet balance",
932
+ aggregatedData.walletBalance
933
+ ? `${aggregatedData.walletBalance.token}=${aggregatedData.walletBalance.amount}`
934
+ : undefined,
935
+ );
936
+ console.log(
937
+ "vault asset balance",
938
+ aggregatedData.vaultAssetBalance
939
+ ? `${aggregatedData.vaultAssetBalance.token}=${aggregatedData.vaultAssetBalance.amount}`
940
+ : undefined,
941
+ );
942
+ console.log(
943
+ "vault usdc balance",
944
+ aggregatedData.vaultUsdcBalance
945
+ ? `${aggregatedData.vaultUsdcBalance.token}=${aggregatedData.vaultUsdcBalance.amount}`
946
+ : undefined,
947
+ );
948
+
949
+ // vesu info
950
+ console.log(
951
+ "vesu pool states",
952
+ aggregatedData.vesuPoolStates
953
+ .map(
954
+ (p) =>
955
+ `${p.poolId.shortString()}=${p.collateralAmount} ${p.debtAmount}`,
956
+ )
957
+ .join(", "),
958
+ );
959
+ console.log("vesu borrow capacity", aggregatedData.vesuBorrowCapacity);
960
+ console.log(
961
+ "vesu rebalance",
962
+ aggregatedData.vesuRebalance.map(String).join(", "),
963
+ );
964
+ console.log("vesu per pool debt deltas to borrow", aggregatedData.vesuPerPoolDebtDeltasToBorrow.join(", "));
965
+ // extended info
966
+ console.log("extended balance", aggregatedData.extendedBalance);
967
+ console.log("extended equity", aggregatedData.extendedEquity);
968
+ console.log("extended available for trade", aggregatedData.extendedAvailableForTrade);
969
+ console.log("extended available for withdrawal", aggregatedData.extendedAvailableForWithdrawal);
970
+ console.log("extended unrealised pnl", aggregatedData.extendedUnrealisedPnl);
971
+ console.log("extended pending deposit", aggregatedData.extendedPendingDeposit);
972
+ console.log(
973
+ "extended positions",
974
+ aggregatedData.extendedPositions
975
+ .map(
976
+ (p) => `${p.instrument}=${p.size} ${p.valueUsd}`,
977
+ )
978
+ .join(", "),
979
+ );
980
+
981
+ return aggregatedData
982
+ }
983
+
984
+ /**
985
+ * Initialise derived views for a solve cycle. Mutates only when pending
986
+ * withdrawal from Extended is in transit (credits wallet raw balance).
752
987
  */
753
988
  initBudget(): void {
754
- const debtDeltaNum = this.vesuPerPoolDebtDeltasToBorrow.reduce((a, b) => a + b.toNumber(), 0);
755
- let totalUnusedUsd = this.unusedBalance.reduce((a, b) => a + b.usdValue, 0);
756
- if (debtDeltaNum > 0) totalUnusedUsd += debtDeltaNum;
757
- const extAvailTrade = this.extendedBalance?.availableForTrade?.toNumber() ?? 0;
758
- if (extAvailTrade > 0) totalUnusedUsd += extAvailTrade;
759
- if (debtDeltaNum > 0) totalUnusedUsd += debtDeltaNum;
760
-
761
- this._vaUsd = this.vaultBalance?.usdValue ?? 0;
762
- this._walletUsd = this.walletBalance?.usdValue ?? 0;
763
- this._extAvailWithdraw = this.extendedBalance?.availableForWithdrawal?.toNumber() ?? 0;
764
- this._extAvailUpnl = this.extendedBalance?.unrealisedPnl?.toNumber() ?? 0;
765
- this._extAvailTrade = extAvailTrade;
766
- this._extPendingDeposit = this.extendedBalance?.pendingDeposit?.toNumber() ?? 0;
767
- this._vesuBorrowCapacity = debtDeltaNum;
768
- this._totalUnused = totalUnusedUsd;
769
-
770
- // ! cannot assume like this. the assumpotion, raher is opposite. also, be
771
989
  const pendingDeposit = this.extendedBalance?.pendingDeposit?.toNumber() ?? 0;
772
- if (pendingDeposit > 0) {
773
- this._extAvailTrade += pendingDeposit;
774
- this._totalUnused += pendingDeposit;
775
- logger.debug(`SolveBudget::initBudget pendingDeposit=${pendingDeposit} -> increased extAvailTrade`);
776
- } else if (pendingDeposit < 0) {
990
+ if (pendingDeposit < 0) {
777
991
  const inTransit = Math.abs(pendingDeposit);
778
- this._walletUsd += inTransit;
779
- this._totalUnused += inTransit;
780
- logger.debug(`SolveBudget::initBudget pendingDeposit=${pendingDeposit} -> increased walletUsd by ${inTransit}`);
992
+ if (this.walletBalance) {
993
+ this._addUsdToTokenBalance(this.walletBalance, inTransit);
994
+ }
995
+ logger.debug(`SolveBudget::initBudget pendingDeposit=${pendingDeposit} -> increased wallet raw USD by ${inTransit}`);
781
996
  }
997
+
998
+ this._recomputeUnusedBalance();
999
+ }
1000
+
1001
+ /**
1002
+ * Apply a safety buffer to all liquid balances (VA, wallet, extended trade/withdraw/upnl,
1003
+ * unused balances). Call after withdrawal classification but before LTV/deposit classifiers
1004
+ * so that withdrawal uses full raw amounts while subsequent classifiers see buffered values.
1005
+ */
1006
+ applyBuffer(factor: number): void {
1007
+ if (factor <= 0 || factor >= 1) return;
1008
+ const mult = 1 - factor;
1009
+
1010
+ const scaleTokenBalance = (tb: TokenBalance | null) => {
1011
+ if (!tb) return;
1012
+ const newAmount = tb.amount.multipliedBy(mult);
1013
+ tb.amount = new Web3Number(newAmount.toFixed(tb.token.decimals), tb.token.decimals);
1014
+ tb.usdValue = tb.usdValue * mult;
1015
+ };
1016
+
1017
+ scaleTokenBalance(this.vaultAssetBalance);
1018
+ scaleTokenBalance(this.vaultUsdcBalance);
1019
+ scaleTokenBalance(this.walletBalance);
1020
+ for (const ub of this.unusedBalance) {
1021
+ scaleTokenBalance(ub);
1022
+ }
1023
+
1024
+ if (this.extendedBalance) {
1025
+ this.extendedBalance.availableForTrade = this.extendedBalance.availableForTrade.multipliedBy(mult);
1026
+ this.extendedBalance.availableForWithdrawal = this.extendedBalance.availableForWithdrawal.multipliedBy(mult);
1027
+ this.extendedBalance.unrealisedPnl = this.extendedBalance.unrealisedPnl.multipliedBy(mult);
1028
+ }
1029
+
1030
+ this._recomputeUnusedBalance();
1031
+ }
1032
+
1033
+ get vesuPoolState(): VesuPoolState {
1034
+ assert(this.vesuPoolStates.length === 1, 'SolveBudget::vesuPoolState: vesuPoolStates length must be 1');
1035
+ return this.vesuPoolStates[0];
1036
+ }
1037
+
1038
+ // ── Derived getters (buffered where applicable) ─────────────────────
1039
+
1040
+ /** Buffered VA USD: strategy-asset slot + optional USDC slot. */
1041
+ get vaUsd(): number {
1042
+ return this.bufferedTokenUsd(this.vaultAssetBalance) + this.bufferedTokenUsd(this.vaultUsdcBalance);
1043
+ }
1044
+
1045
+ /** Buffered USD in VA strategy-asset bucket only. */
1046
+ get vaAssetUsd(): number {
1047
+ return this.bufferedTokenUsd(this.vaultAssetBalance);
1048
+ }
1049
+
1050
+ /** Buffered USD in VA USDC bucket (0 when asset === USDC). */
1051
+ get vaUsdcUsd(): number {
1052
+ return this.bufferedTokenUsd(this.vaultUsdcBalance);
1053
+ }
1054
+
1055
+ get walletUsd(): number {
1056
+ return this.bufferedUsd(this._rawTokenUsd(this.walletBalance));
1057
+ }
1058
+
1059
+ get vaWalletUsd(): number {
1060
+ // va buffered + wallet raw
1061
+ return this.vaUsd + this.walletUsd;
1062
+ }
1063
+
1064
+ get extAvailWithdraw(): number {
1065
+ const raw = this.extendedBalance?.availableForWithdrawal?.toNumber() ?? 0;
1066
+ return this.bufferedUsd(raw);
1067
+ }
1068
+
1069
+ get extAvailUpnl(): number {
1070
+ const raw = this.extendedBalance?.unrealisedPnl?.toNumber() ?? 0;
1071
+ return this.bufferedUsd(raw);
1072
+ }
1073
+
1074
+ /**
1075
+ * Buffered Extended available-for-trade plus positive {@link ExtendedBalanceState.pendingDeposit}
1076
+ * (deposit in transit is usable the same way as the pre-buffer implementation).
1077
+ */
1078
+ get extAvailTrade(): number {
1079
+ const raw = this.extendedBalance?.availableForTrade?.toNumber() ?? 0;
1080
+ let v = this.bufferedUsd(raw);
1081
+ const pd = this.extendedBalance?.pendingDeposit?.toNumber() ?? 0;
1082
+ if (pd > 0) v += pd;
1083
+ return v;
1084
+ }
1085
+
1086
+ get extPendingDeposit(): number {
1087
+ return this.extendedBalance?.pendingDeposit?.toNumber() ?? 0;
1088
+ }
1089
+
1090
+ /**
1091
+ * Aggregate positive per-pool borrow headroom (USD). Repay/borrow routes update
1092
+ * {@link vesuPerPoolDebtDeltasToBorrow}; no separate counter.
1093
+ */
1094
+ get vesuBorrowCapacity(): number {
1095
+ return this.vesuPerPoolDebtDeltasToBorrow.reduce(
1096
+ (a, d) => a + Math.max(0, d.toNumber()),
1097
+ 0,
1098
+ );
1099
+ }
1100
+
1101
+ /** Diagnostic: buffered idle + positive debt delta + buffered Extended afT + in-flight deposit. */
1102
+ get totalUnused(): number {
1103
+ const debtSum = this.vesuPerPoolDebtDeltasToBorrow.reduce((a, b) => a + b.toNumber(), 0);
1104
+ let u = this.unusedBalance.reduce((a, b) => a + this.bufferedTokenUsd(b), 0);
1105
+ if (debtSum > 0) u += debtSum;
1106
+ const rawAft = this.extendedBalance?.availableForTrade?.toNumber() ?? 0;
1107
+ const aftBuf = this.bufferedUsd(rawAft);
1108
+ if (aftBuf > 0) u += aftBuf;
1109
+ const pd = this.extendedBalance?.pendingDeposit?.toNumber() ?? 0;
1110
+ if (pd > 0) u += pd;
1111
+ return u;
1112
+ }
1113
+
1114
+ /** Sum of buffered USD across merged unused-balance rows (VA + wallet). */
1115
+ get unusedBalancesBufferedUsdSum(): number {
1116
+ return this.unusedBalance.reduce((a, b) => a + this.bufferedTokenUsd(b), 0);
1117
+ }
1118
+
1119
+ /** Read-only snapshot view for validation / logging. */
1120
+ get unusedBalanceRows(): readonly TokenBalance[] {
1121
+ return this.unusedBalance;
1122
+ }
1123
+
1124
+ /** Read-only Vesu pool view for solve computations. */
1125
+ get vesuPools(): readonly VesuPoolState[] {
1126
+ return this.vesuPoolStates;
1127
+ }
1128
+
1129
+ /** Read-only Extended positions view for solve computations. */
1130
+ get extendedPositionsView(): readonly ExtendedPositionState[] {
1131
+ return this.extendedPositions;
1132
+ }
1133
+
1134
+ /** Read-only Extended balance view for diagnostics / margin checks. */
1135
+ get extendedBalanceView(): ExtendedBalanceState | null {
1136
+ return this.extendedBalance;
1137
+ }
1138
+
1139
+ /** Current debt deltas per pool (positive=borrow, negative=repay). */
1140
+ get vesuDebtDeltas(): readonly Web3Number[] {
1141
+ return this.vesuPerPoolDebtDeltasToBorrow;
1142
+ }
1143
+
1144
+ /** Per-pool rebalance flags derived from target HF checks. */
1145
+ get vesuRebalanceFlags(): readonly boolean[] {
1146
+ return this.shouldVesuRebalance;
1147
+ }
1148
+
1149
+ /** Raw USD in VA (USDC slot + asset slot); spend caps when executing transfers. */
1150
+ private _vaRawUsd(): number {
1151
+ return this._rawTokenUsd(this.vaultUsdcBalance) + this._rawTokenUsd(this.vaultAssetBalance);
1152
+ }
1153
+
1154
+ private _walletRawUsd(): number {
1155
+ return this._rawTokenUsd(this.walletBalance);
1156
+ }
1157
+
1158
+ // ── Token snapshot helpers (keep vault / wallet / unusedBalance aligned) ─
1159
+
1160
+ /** Remove up to `usd` notional from a token balance, scaling token amount proportionally. */
1161
+ private _deductUsdFromTokenBalance(tb: TokenBalance, usd: number): void {
1162
+ if (usd <= 0) return;
1163
+ const take = Math.min(usd, tb.usdValue);
1164
+ if (take <= 0) return;
1165
+ const oldUsd = tb.usdValue;
1166
+ const newUsd = Math.max(0, oldUsd - take);
1167
+ tb.usdValue = newUsd;
1168
+ if (oldUsd <= 0) return;
1169
+ const ratio = newUsd / oldUsd;
1170
+ tb.amount = new Web3Number(
1171
+ (tb.amount.toNumber() * ratio).toFixed(tb.token.decimals),
1172
+ tb.token.decimals,
1173
+ );
782
1174
  }
783
1175
 
784
- // ── Read-only getters ────────────────────────────────────────────────
1176
+ /** Add USD notional; infers price from current amount/usd when possible, else 1:1. */
1177
+ private _addUsdToTokenBalance(tb: TokenBalance, usd: number): void {
1178
+ if (usd <= 0) return;
1179
+ const amtNum = tb.amount.toNumber();
1180
+ const price =
1181
+ amtNum > 0 && tb.usdValue > 0 ? tb.usdValue / amtNum : 1;
1182
+ const deltaTok = usd / price;
1183
+ tb.usdValue += usd;
1184
+ tb.amount = tb.amount.plus(
1185
+ new Web3Number(deltaTok.toFixed(tb.token.decimals), tb.token.decimals),
1186
+ );
1187
+ }
1188
+
1189
+ /**
1190
+ * Rebuilds {@link unusedBalance} from vault + wallet snapshots (same merge as refresh).
1191
+ */
1192
+ private _recomputeUnusedBalance(): void {
1193
+ const balanceMap = new Map<string, TokenBalance>();
1194
+
1195
+ const merge = (b: TokenBalance | null) => {
1196
+ if (!b) return;
1197
+ const key = b.token.address.toString();
1198
+ const row: TokenBalance = {
1199
+ token: b.token,
1200
+ amount: new Web3Number(b.amount.toFixed(b.token.decimals), b.token.decimals),
1201
+ usdValue: b.usdValue,
1202
+ };
1203
+ const existing = balanceMap.get(key);
1204
+ if (existing) {
1205
+ existing.amount = new Web3Number(
1206
+ existing.amount.plus(row.amount).toFixed(existing.token.decimals),
1207
+ existing.token.decimals,
1208
+ );
1209
+ existing.usdValue += row.usdValue;
1210
+ } else {
1211
+ balanceMap.set(key, row);
1212
+ }
1213
+ };
785
1214
 
786
- get vaUsd(): number { return this._vaUsd; }
787
- get walletUsd(): number { return this._walletUsd; }
788
- get vaWalletUsd(): number { return this._vaUsd + this._walletUsd; }
789
- get extAvailWithdraw(): number { return this._extAvailWithdraw; }
790
- get extAvailUpnl(): number { return this._extAvailUpnl; }
791
- get extAvailTrade(): number { return this._extAvailTrade; }
792
- get vesuBorrowCapacity(): number { return this._vesuBorrowCapacity; }
793
- get totalUnused(): number { return this._totalUnused; }
794
- get extPendingDeposit(): number { return this._extPendingDeposit; }
1215
+ merge(this.vaultAssetBalance);
1216
+ merge(this.vaultUsdcBalance);
1217
+ merge(this.walletBalance);
1218
+
1219
+ this.unusedBalance = Array.from(balanceMap.values());
1220
+ }
795
1221
 
796
1222
  // ── Spend methods (return amount consumed, auto-decrement totalUnused) ─
797
1223
 
798
- spendVA(desired: number): number {
799
- const used = Math.min(this._vaUsd, Math.max(0, desired));
800
- this._vaUsd -= used;
801
- this._totalUnused -= used;
802
- logger.debug(`SolveBudget::spendVA used=${used}, vaUsd=${this._vaUsd}, totalUnused=${this._totalUnused}`);
803
- return used;
1224
+ /**
1225
+ * Spend VA **raw** USD (up to {@link vaRawUsd}). Prefer {@link vaultUsdcBalance} when present, then {@link vaultAssetBalance}.
1226
+ */
1227
+ spendVA(rawDesired: number): number {
1228
+ const capRaw = this._vaRawUsd();
1229
+ const usedRaw = Math.min(capRaw, Math.max(0, rawDesired));
1230
+ if (usedRaw <= CASE_THRESHOLD_USD) return 0;
1231
+ let rem = usedRaw;
1232
+ if (rem > 0 && this.vaultUsdcBalance && this.vaultUsdcBalance.usdValue > 0) {
1233
+ const fromUsdc = Math.min(rem, this.vaultUsdcBalance.usdValue);
1234
+ this._deductUsdFromTokenBalance(this.vaultUsdcBalance, fromUsdc);
1235
+ rem -= fromUsdc;
1236
+ }
1237
+ if (rem > 0 && this.vaultAssetBalance) {
1238
+ this._deductUsdFromTokenBalance(this.vaultAssetBalance, rem);
1239
+ }
1240
+ this._recomputeUnusedBalance();
1241
+ logger.debug(`SolveBudget::spendVA usedRaw=${usedRaw}, vaUsd=${this.vaUsd}, totalUnused=${this.totalUnused}`);
1242
+ return usedRaw;
804
1243
  }
805
1244
 
806
- addToVA(amount: number): void {
807
- assert(amount >= 0, 'SolveBudget::addToVA amount must be positive');
808
- this._vaUsd += amount;
809
- this._totalUnused += amount;
810
- logger.debug(`SolveBudget::addToVA amount=${amount}, vaUsd=${this._vaUsd}, totalUnused=${this._totalUnused}`);
1245
+ /**
1246
+ * Spend nominal/raw USD from VA (e.g. Vesu repay, on-chain USDC). Does not apply the safety buffer to the cap.
1247
+ */
1248
+ spendVaRawUsd(rawUsdDesired: number): number {
1249
+ const capRaw =
1250
+ this._rawTokenUsd(this.vaultUsdcBalance) + this._rawTokenUsd(this.vaultAssetBalance);
1251
+ const usedRaw = Math.min(capRaw, Math.max(0, rawUsdDesired));
1252
+ if (usedRaw <= CASE_THRESHOLD_USD) return 0;
1253
+ let rem = usedRaw;
1254
+ if (rem > 0 && this.vaultUsdcBalance && this.vaultUsdcBalance.usdValue > 0) {
1255
+ const fromUsdc = Math.min(rem, this.vaultUsdcBalance.usdValue);
1256
+ this._deductUsdFromTokenBalance(this.vaultUsdcBalance, fromUsdc);
1257
+ rem -= fromUsdc;
1258
+ }
1259
+ if (rem > 0 && this.vaultAssetBalance) {
1260
+ this._deductUsdFromTokenBalance(this.vaultAssetBalance, rem);
1261
+ }
1262
+ this._recomputeUnusedBalance();
1263
+ logger.debug(`SolveBudget::spendVaRawUsd usedRaw=${usedRaw}, vaUsd=${this.vaUsd}, totalUnused=${this.totalUnused}`);
1264
+ return usedRaw;
811
1265
  }
812
1266
 
813
- spendWallet(desired: number): number {
814
- const used = Math.min(this._walletUsd, Math.max(0, desired));
815
- this._walletUsd -= used;
816
- this._totalUnused -= used;
817
- logger.debug(`SolveBudget::spendWallet used=${used}, walletUsd=${this._walletUsd}, totalUnused=${this._totalUnused}`);
818
- return used;
1267
+ /**
1268
+ * Add **raw nominal USD** to VA (borrow proceeds, wallet→VA in raw USDC, etc.).
1269
+ */
1270
+ addToVA(rawUsd: number): void {
1271
+ assert(rawUsd >= 0, 'SolveBudget::addToVA amount must be positive');
1272
+ if (rawUsd === 0) return;
1273
+ if (this.vaultUsdcBalance) {
1274
+ this._addUsdToTokenBalance(this.vaultUsdcBalance, rawUsd);
1275
+ } else if (this.vaultAssetBalance) {
1276
+ this._addUsdToTokenBalance(this.vaultAssetBalance, rawUsd);
1277
+ }
1278
+ this._recomputeUnusedBalance();
1279
+ logger.debug(`SolveBudget::addToVA rawUsd=${rawUsd}, vaUsd=${this.vaUsd}, totalUnused=${this.totalUnused}`);
819
1280
  }
820
1281
 
821
- addToWallet(amount: number): void {
822
- assert(amount >= 0, 'SolveBudget::addToWallet amount must be positive');
823
- this._walletUsd += amount;
824
- this._totalUnused += amount;
825
- logger.debug(`SolveBudget::addToWallet amount=${amount}, walletUsd=${this._walletUsd}, totalUnused=${this._totalUnused}`);
1282
+ spendWallet(rawDesired: number): number {
1283
+ const capRaw = this._walletRawUsd();
1284
+ const usedRaw = Math.min(capRaw, Math.max(0, rawDesired));
1285
+ if (usedRaw <= CASE_THRESHOLD_USD) return 0;
1286
+ if (this.walletBalance) {
1287
+ this._deductUsdFromTokenBalance(this.walletBalance, usedRaw);
1288
+ }
1289
+ this._recomputeUnusedBalance();
1290
+ logger.debug(`SolveBudget::spendWallet usedRaw=${usedRaw}, walletUsd=${this.walletUsd}, totalUnused=${this.totalUnused}`);
1291
+ return usedRaw;
826
1292
  }
827
1293
 
828
- spendVAWallet(desired: number): number {
829
- let remaining = Math.max(0, desired);
1294
+ /** Add **raw nominal USD** to the operator wallet balance (e.g. Extended→wallet withdrawal). */
1295
+ addToWallet(rawUsd: number): void {
1296
+ assert(rawUsd >= 0, 'SolveBudget::addToWallet amount must be positive');
1297
+ if (rawUsd === 0) return;
1298
+ if (this.walletBalance) {
1299
+ this._addUsdToTokenBalance(this.walletBalance, rawUsd);
1300
+ }
1301
+ this._recomputeUnusedBalance();
1302
+ logger.debug(`SolveBudget::addToWallet rawUsd=${rawUsd}, walletUsd=${this.walletUsd}, totalUnused=${this.totalUnused}`);
1303
+ }
1304
+
1305
+ spendVAWallet(rawDesired: number): number {
1306
+ let remaining = Math.max(0, rawDesired);
830
1307
  const vaSpent = this.spendVA(remaining);
831
1308
  remaining -= vaSpent;
832
1309
  const walletSpent = this.spendWallet(remaining);
833
1310
  return vaSpent + walletSpent;
834
1311
  }
835
1312
 
836
- private _updateExtAvailWithdraw(desired: number, isSpend: boolean): number {
837
- let amount = desired;
838
- assert(amount > 0, 'SolveBudget::_updateExtAvailWithdraw amount must be positive');
1313
+ private _updateExtAvailWithdraw(desiredRaw: number, isSpend: boolean): number {
1314
+ assert(desiredRaw > 0, 'SolveBudget::_updateExtAvailWithdraw amount must be positive');
1315
+ let rawDelta: number;
839
1316
  if (isSpend) {
840
- amount = Math.min(this._extAvailWithdraw, Math.max(0, desired));
841
- amount = -amount; // invert sign
1317
+ const capRaw = this.extendedBalance?.availableForWithdrawal?.toNumber() ?? 0;
1318
+ const useRaw = Math.min(capRaw, desiredRaw);
1319
+ if (useRaw <= CASE_THRESHOLD_USD) return 0;
1320
+ rawDelta = -useRaw;
1321
+ } else {
1322
+ rawDelta = desiredRaw;
842
1323
  }
843
- this._extAvailWithdraw += amount;
844
- this._totalUnused += amount;
845
- this._extAvailTrade += amount;
846
1324
 
847
- // update extended balances
848
1325
  if (this.extendedBalance) {
849
- this.extendedBalance.availableForWithdrawal = safeUsdcWeb3Number(this.extendedBalance.availableForWithdrawal.toNumber() + amount);
850
- this.extendedBalance.availableForTrade = safeUsdcWeb3Number(this.extendedBalance.availableForTrade.toNumber() + amount);
851
- this.extendedBalance.balance = safeUsdcWeb3Number(this.extendedBalance.balance.toNumber() + amount);
852
- this.extendedBalance.equity = safeUsdcWeb3Number(this.extendedBalance.equity.toNumber() + amount);
1326
+ this.extendedBalance.availableForWithdrawal = safeUsdcWeb3Number(this.extendedBalance.availableForWithdrawal.toNumber() + rawDelta);
1327
+ this.extendedBalance.availableForTrade = safeUsdcWeb3Number(this.extendedBalance.availableForTrade.toNumber() + rawDelta);
1328
+ this.extendedBalance.balance = safeUsdcWeb3Number(this.extendedBalance.balance.toNumber() + rawDelta);
1329
+ this.extendedBalance.equity = safeUsdcWeb3Number(this.extendedBalance.equity.toNumber() + rawDelta);
853
1330
  }
854
- logger.debug(`SolveBudget::updateExtAvailWithdraw amount=${amount}, extAvailWithdraw=${this._extAvailWithdraw}, totalUnused=${this._totalUnused}`);
855
- return amount;
1331
+ logger.debug(`SolveBudget::updateExtAvailWithdraw rawDelta=${rawDelta}, extAvailWithdraw=${this.extAvailWithdraw}, totalUnused=${this.totalUnused}`);
1332
+ return rawDelta;
856
1333
  }
857
1334
 
858
- private _updateExtAvailUpnl(desired: number, isSpend: boolean): number {
859
- let amount = desired;
860
- assert(amount > 0, 'SolveBudget::_updateExtAvailUpnl amount must be positive');
1335
+ private _updateExtAvailUpnl(desiredRaw: number, isSpend: boolean): number {
1336
+ assert(desiredRaw > 0, 'SolveBudget::_updateExtAvailUpnl amount must be positive');
1337
+ let rawDelta: number;
861
1338
  if (isSpend) {
862
- amount = Math.min(this._extAvailUpnl, Math.max(0, desired));
863
- amount = -amount; // invert sign
1339
+ const capRaw = this.extendedBalance?.unrealisedPnl?.toNumber() ?? 0;
1340
+ const useRaw = Math.min(capRaw, desiredRaw);
1341
+ if (useRaw <= CASE_THRESHOLD_USD) return 0;
1342
+ rawDelta = -useRaw;
1343
+ } else {
1344
+ rawDelta = desiredRaw;
864
1345
  }
865
- this._extAvailUpnl += amount;
866
- this._totalUnused += amount;
867
1346
 
868
- // update extended balances
869
1347
  if (this.extendedBalance) {
870
- this.extendedBalance.unrealisedPnl = safeUsdcWeb3Number(this.extendedBalance.unrealisedPnl.toNumber() + amount);
871
- this.extendedBalance.balance = safeUsdcWeb3Number(this.extendedBalance.balance.toNumber() + amount);
872
- this.extendedBalance.equity = safeUsdcWeb3Number(this.extendedBalance.equity.toNumber() + amount);
873
- this.extendedBalance.availableForTrade = safeUsdcWeb3Number(this.extendedBalance.availableForTrade.toNumber() + amount);
1348
+ this.extendedBalance.unrealisedPnl = safeUsdcWeb3Number(this.extendedBalance.unrealisedPnl.toNumber() + rawDelta);
1349
+ this.extendedBalance.balance = safeUsdcWeb3Number(this.extendedBalance.balance.toNumber() + rawDelta);
1350
+ this.extendedBalance.equity = safeUsdcWeb3Number(this.extendedBalance.equity.toNumber() + rawDelta);
1351
+ this.extendedBalance.availableForTrade = safeUsdcWeb3Number(this.extendedBalance.availableForTrade.toNumber() + rawDelta);
874
1352
  }
875
- logger.debug(`SolveBudget::updateExtAvailUpnl amount=${amount}, extAvailUpnl=${this._extAvailUpnl}, totalUnused=${this._totalUnused}`);
876
- return amount;
1353
+ logger.debug(`SolveBudget::updateExtAvailUpnl rawDelta=${rawDelta}, extAvailUpnl=${this.extAvailUpnl}, totalUnused=${this.totalUnused}`);
1354
+ return rawDelta;
877
1355
  }
878
1356
 
879
- spendExtAvailTrade(desired: number): number {
880
- const used = this._updateExtAvailWithdraw(desired, true);
881
- const usedUpnl = this._updateExtAvailUpnl(desired, true);
882
- logger.debug(`SolveBudget::updateExtAvailTrade amount=${used + usedUpnl}, extAvailTrade=${this._extAvailTrade}, totalUnused=${this._totalUnused}`);
1357
+ spendExtAvailTrade(rawDesired: number): number {
1358
+ const used = this._updateExtAvailWithdraw(rawDesired, true);
1359
+ const usedUpnl = this._updateExtAvailUpnl(rawDesired, true);
1360
+ logger.debug(`SolveBudget::updateExtAvailTrade rawSum=${used + usedUpnl}, extAvailTrade=${this.extAvailTrade}, totalUnused=${this.totalUnused}`);
883
1361
  return used + usedUpnl;
884
1362
  }
885
1363
 
886
- spendExtAvailUpnl(desired: number): number {
887
- return this._updateExtAvailUpnl(desired, true);
1364
+ // simply reduces available amounts, but maintains equity and balance.
1365
+ spendExtAvailTradeToEquityOnly(rawDesired: number): number {
1366
+ const used = this._updateExtAvailWithdraw(rawDesired, true);
1367
+ const remaining = rawDesired - Math.abs(used);
1368
+ const usedUpnl = remaining > 0 ? this._updateExtAvailUpnl(remaining, true) : 0;
1369
+ if (this.extendedBalance) {
1370
+ // add whats subtracted earlier to equity
1371
+ const net = Math.abs(used) + Math.abs(usedUpnl);
1372
+ if (net.toFixed(0) != rawDesired.toFixed(0)) {
1373
+ throw new Error(`SolveBudget::spendExtAvailTradeToEquityOnly net=${net} != rawDesired=${rawDesired}`);
1374
+ }
1375
+ this.extendedBalance.equity = safeUsdcWeb3Number(this.extendedBalance.equity.toNumber() + net);
1376
+ this.extendedBalance.balance = safeUsdcWeb3Number(this.extendedBalance.balance.toNumber() + net);
1377
+ }
1378
+ logger.debug(`SolveBudget::updateExtAvailTrade rawSum=${used + usedUpnl}, extAvailTrade=${this.extAvailTrade}, totalUnused=${this.totalUnused}`);
1379
+ return used + usedUpnl;
1380
+ }
1381
+
1382
+ spendExtAvailWithdraw(rawDesired: number): number {
1383
+ return this._updateExtAvailWithdraw(rawDesired, true);
1384
+ }
1385
+
1386
+ spendExtAvailUpnl(rawDesired: number): number {
1387
+ return this._updateExtAvailUpnl(rawDesired, true);
1388
+ }
1389
+
1390
+ /**
1391
+ * Withdraw from Extended **withdrawal bucket only** to operator wallet (planning).
1392
+ * Used when VA must be funded from Extended and withdraw should be exhausted before unrealised PnL.
1393
+ */
1394
+ spendAvailWithdrawToWallet(rawDesired: number): number {
1395
+ const want = Math.max(0, rawDesired);
1396
+ if (want <= CASE_THRESHOLD_USD) return 0;
1397
+ const rawDelta = this._updateExtAvailWithdraw(want, true);
1398
+ if (rawDelta === 0) return 0;
1399
+ const used = -rawDelta;
1400
+ this.addToWallet(used);
1401
+ return used;
1402
+ }
1403
+
1404
+ /**
1405
+ * Required Extended equity (USD) for current open positions: total notional ÷ strategy leverage.
1406
+ * Same basis as {@link ExtendedSVKVesuStateManager._classifyLtvExtended} margin check.
1407
+ */
1408
+ private _extendedMarginRequirementUsd(): number {
1409
+ const lev = calculateExtendedLevergae();
1410
+ if (lev <= 0 || this.extendedPositions.length === 0) return 0;
1411
+ const totalPosUsd = this.extendedPositions.reduce(
1412
+ (s, p) => s + p.valueUsd.toNumber(),
1413
+ 0,
1414
+ );
1415
+ return totalPosUsd / lev;
888
1416
  }
889
1417
 
890
- addToExtAvailTrade(amount: number): void {
891
- this._updateExtAvailWithdraw(amount, false);
892
- logger.debug(`SolveBudget::addToExtAvailTrade amount=${amount}, extAvailTrade=${this._extAvailTrade}, totalUnused=${this._totalUnused}`);
1418
+ /** How much more equity is needed before deposits should increase withdraw / trade availability. */
1419
+ private _extendedEquityShortfallUsd(): number {
1420
+ if (!this.extendedBalance) return 0;
1421
+ const req = this._extendedMarginRequirementUsd();
1422
+ const eq = this.extendedBalance.equity.toNumber();
1423
+ return Math.max(0, req - eq);
1424
+ }
1425
+
1426
+ /**
1427
+ * Credits a USDC inflow on Extended. Fills margin shortfall (balance+equity only) first;
1428
+ * any remainder is credited across balance, equity, availableForWithdrawal, and availableForTrade.
1429
+ */
1430
+ addToExtAvailTrade(rawUsd: number): void {
1431
+ assert(rawUsd >= 0, 'SolveBudget::addToExtAvailTrade amount must be non-negative');
1432
+ if (rawUsd <= CASE_THRESHOLD_USD) return;
1433
+ if (!this.extendedBalance) {
1434
+ logger.warn('SolveBudget::addToExtAvailTrade skipped — no extendedBalance');
1435
+ return;
1436
+ }
1437
+
1438
+ const shortfall = this._extendedEquityShortfallUsd();
1439
+ const toMargin = Math.min(rawUsd, shortfall);
1440
+ const toLiquid = rawUsd - toMargin;
1441
+
1442
+ if (toMargin > CASE_THRESHOLD_USD) {
1443
+ const b = this.extendedBalance.balance.toNumber();
1444
+ const e = this.extendedBalance.equity.toNumber();
1445
+ this.extendedBalance.balance = safeUsdcWeb3Number(b + toMargin);
1446
+ this.extendedBalance.equity = safeUsdcWeb3Number(e + toMargin);
1447
+ logger.debug(
1448
+ `SolveBudget::addToExtAvailTrade margin-first rawUsd=${toMargin} ` +
1449
+ `(shortfallBefore=${shortfall}, balance=${b + toMargin}, equity=${e + toMargin})`,
1450
+ );
1451
+ }
1452
+
1453
+ if (toLiquid > CASE_THRESHOLD_USD) {
1454
+ this._updateExtAvailWithdraw(toLiquid, false);
1455
+ }
1456
+
1457
+ logger.debug(
1458
+ `SolveBudget::addToExtAvailTrade total rawUsd=${rawUsd} toLiquid=${toLiquid} ` +
1459
+ `extAvailTrade=${this.extAvailTrade}, totalUnused=${this.totalUnused}`,
1460
+ );
893
1461
  }
894
1462
 
895
1463
  spendVesuBorrowCapacity(desired: number): { used: number, spendsByPool: Omit<VesuDebtRoute, 'priority' | 'type'>[] } {
896
- const used = Math.min(this._vesuBorrowCapacity, Math.max(0, desired));
897
- this._vesuBorrowCapacity -= used;
898
- // todo check this function is used correctly
899
- this._totalUnused -= used; // we assume only borrowed till ideal LTV
1464
+ const used = Math.min(this.vesuBorrowCapacity, Math.max(0, desired));
900
1465
 
901
1466
  let spendsByPool: Omit<VesuDebtRoute, 'priority' | 'type'>[] = [];
902
1467
  // reduce the debt delta for the pool
@@ -910,17 +1475,13 @@ export class SolveBudget {
910
1475
  spendsByPool.push({ poolId: this.vesuPoolStates[index].poolId, amount: safeUsdcWeb3Number(borrowed), collateralToken: this.vesuPoolStates[index].collateralToken, debtToken: this.vesuPoolStates[index].debtToken });
911
1476
  }
912
1477
 
913
- logger.debug(`SolveBudget::spendVesuBorrowCapacity used=${used}, vesuBorrowCapacity=${this._vesuBorrowCapacity}, totalUnused=${this._totalUnused}`);
1478
+ logger.debug(`SolveBudget::spendVesuBorrowCapacity used=${used}, vesuBorrowCapacity=${this.vesuBorrowCapacity}, totalUnused=${this.totalUnused}`);
914
1479
  return { used, spendsByPool };
915
1480
  }
916
1481
 
917
1482
  repayVesuBorrowCapacity(desired: number): { used: number, spendsByPool: Omit<VesuDebtRoute, 'priority' | 'type'>[] } {
918
1483
  assert(desired > 0, 'SolveBudget::repayVesuBorrowCapacity desired must be positive');
919
- // const used = Math.min(this._vesuBorrowCapacity, Math.max(0, desired));
920
1484
  const used = desired;
921
- // todo check this function is used correctly
922
- this._vesuBorrowCapacity += used;
923
- // wont increase total unused because we are repaying debt
924
1485
 
925
1486
  const spendsByPool: Omit<VesuDebtRoute, 'priority' | 'type'>[] = [];
926
1487
  for (let index = 0; index < this.vesuPerPoolDebtDeltasToBorrow.length; index++) {
@@ -932,7 +1493,7 @@ export class SolveBudget {
932
1493
  spendsByPool.push({ poolId: this.vesuPoolStates[index].poolId, amount: safeUsdcWeb3Number(-repaid), collateralToken: this.vesuPoolStates[index].collateralToken, debtToken: this.vesuPoolStates[index].debtToken });
933
1494
  }
934
1495
 
935
- logger.debug(`SolveBudget::repayVesuBorrowCapacity used=${used}, vesuBorrowCapacity=${this._vesuBorrowCapacity}, totalUnused=${this._totalUnused}`);
1496
+ logger.debug(`SolveBudget::repayVesuBorrowCapacity used=${used}, vesuBorrowCapacity=${this.vesuBorrowCapacity}, totalUnused=${this.totalUnused}`);
936
1497
  return { used, spendsByPool };
937
1498
  }
938
1499
 
@@ -958,14 +1519,28 @@ export class SolveBudget {
958
1519
  // recompute per pool deltas here
959
1520
  const vesuPerPoolDebtDeltasToBorrow = this._computeperPoolDebtDeltasToBorrow();
960
1521
  this.vesuPerPoolDebtDeltasToBorrow = vesuPerPoolDebtDeltasToBorrow.map((item) => item.deltaDebt);
961
- const sum = this.vesuPerPoolDebtDeltasToBorrow.reduce((a, b) => a.plus(b), new Web3Number(0, USDC_TOKEN_DECIMALS));
962
- this._vesuBorrowCapacity = sum.toNumber();
963
1522
  this.shouldVesuRebalance = vesuPerPoolDebtDeltasToBorrow.map((item) => item.shouldRebalance);
964
1523
  }
965
1524
 
966
- /** Update an Extended position's size after a lever route. Creates the position if new. */
967
- applyExtendedExposureDelta(instrument: string, sizeDelta: Web3Number): void {
968
- const pos = this.extendedPositions.find((p) => p.instrument === instrument);
1525
+ /**
1526
+ * Update Extended position size after a lever route; sync {@link ExtendedPositionState.valueUsd}
1527
+ * and margin buckets (released USD on decrease, locked USD on increase).
1528
+ *
1529
+ * @param collateralPriceUsd BTC collateral price for notional / margin math; if omitted, uses
1530
+ * existing valueUsd / |size| or the first Vesu pool collateral price.
1531
+ */
1532
+ applyExtendedExposureDelta(
1533
+ instrument: string,
1534
+ sizeDelta: Web3Number,
1535
+ collateralPriceUsd?: number,
1536
+ ): void {
1537
+ const btcEps = 10 ** -COLLATERAL_PRECISION;
1538
+ const lev = calculateExtendedLevergae();
1539
+
1540
+ let pos = this.extendedPositions.find((p) => p.instrument === instrument);
1541
+ const oldAbs = pos ? Math.abs(pos.size.toNumber()) : 0;
1542
+ const oldValUsd = pos ? pos.valueUsd.toNumber() : 0;
1543
+
969
1544
  if (pos) {
970
1545
  pos.size = new Web3Number(pos.size.plus(sizeDelta).toFixed(8), 8);
971
1546
  } else if (sizeDelta.toNumber() !== 0) {
@@ -976,6 +1551,62 @@ export class SolveBudget {
976
1551
  valueUsd: new Web3Number(0, USDC_TOKEN_DECIMALS),
977
1552
  leverage: '0',
978
1553
  });
1554
+ pos = this.extendedPositions[this.extendedPositions.length - 1];
1555
+ } else {
1556
+ return;
1557
+ }
1558
+
1559
+ const newAbs = Math.abs(pos.size.toNumber());
1560
+ let price = collateralPriceUsd;
1561
+ if (price === undefined || price <= 0) {
1562
+ price = oldAbs > btcEps ? oldValUsd / oldAbs : (this.vesuPoolStates[0]?.collateralPrice ?? 0);
1563
+ }
1564
+
1565
+ if (price > 0) {
1566
+ pos.valueUsd = new Web3Number(
1567
+ (newAbs * price).toFixed(USDC_TOKEN_DECIMALS),
1568
+ USDC_TOKEN_DECIMALS,
1569
+ );
1570
+ }
1571
+
1572
+ if (!this.extendedBalance || lev <= 0 || price <= 0) return;
1573
+
1574
+ const dAbs = newAbs - oldAbs;
1575
+ if (dAbs < -btcEps) {
1576
+ const releasedUsd = (-dAbs) * price / lev;
1577
+ if (releasedUsd > CASE_THRESHOLD_USD) {
1578
+ this.addToExtAvailTrade(releasedUsd);
1579
+ }
1580
+ } else if (dAbs > btcEps) {
1581
+ const lockedUsd = dAbs * price / lev;
1582
+ if (lockedUsd > CASE_THRESHOLD_USD) {
1583
+ this._lockExtendedMarginUsd(lockedUsd);
1584
+ }
1585
+ }
1586
+ }
1587
+
1588
+ /** Pull margin for larger Extended exposure from liquid buckets, then balance/equity. */
1589
+ private _lockExtendedMarginUsd(lockedUsd: number): void {
1590
+ if (lockedUsd <= CASE_THRESHOLD_USD || !this.extendedBalance) return;
1591
+ let rem = lockedUsd;
1592
+
1593
+ const uw = Math.min(rem, Math.max(0, this.extendedBalance.availableForWithdrawal.toNumber()));
1594
+ if (uw > 0) {
1595
+ this._updateExtAvailWithdraw(uw, true);
1596
+ rem -= uw;
1597
+ }
1598
+ if (rem > 0) {
1599
+ const uu = Math.min(rem, Math.max(0, this.extendedBalance.unrealisedPnl.toNumber()));
1600
+ if (uu > 0) {
1601
+ this._updateExtAvailUpnl(uu, true);
1602
+ rem -= uu;
1603
+ }
1604
+ }
1605
+ if (rem > 0) {
1606
+ const b = this.extendedBalance.balance.toNumber();
1607
+ const e = this.extendedBalance.equity.toNumber();
1608
+ this.extendedBalance.balance = safeUsdcWeb3Number(b - rem);
1609
+ this.extendedBalance.equity = safeUsdcWeb3Number(e - rem);
979
1610
  }
980
1611
  }
981
1612
 
@@ -1009,6 +1640,35 @@ export class SolveBudget {
1009
1640
  }
1010
1641
  }
1011
1642
 
1643
+ export function createSolveBudgetFromRawState(params: {
1644
+ assetToken: TokenInfo;
1645
+ usdcToken: TokenInfo;
1646
+ unusedBalance: TokenBalance[];
1647
+ walletBalance: TokenBalance | null;
1648
+ vaultAssetBalance: TokenBalance | null;
1649
+ vaultUsdcBalance: TokenBalance | null;
1650
+ extendedPositions: ExtendedPositionState[];
1651
+ extendedBalance: ExtendedBalanceState | null;
1652
+ vesuPoolStates: VesuPoolState[];
1653
+ limitBalanceBufferFactor?: number;
1654
+ }): SolveBudget {
1655
+ const budget = new SolveBudget({
1656
+ assetToken: params.assetToken,
1657
+ usdcToken: params.usdcToken,
1658
+ unusedBalance: params.unusedBalance,
1659
+ walletBalance: params.walletBalance,
1660
+ vaultAssetBalance: params.vaultAssetBalance,
1661
+ vaultUsdcBalance: params.vaultUsdcBalance,
1662
+ extendedPositions: params.extendedPositions,
1663
+ extendedBalance: params.extendedBalance,
1664
+ vesuPoolStates: params.vesuPoolStates,
1665
+ });
1666
+ if (params.limitBalanceBufferFactor && params.limitBalanceBufferFactor > 0) {
1667
+ budget.applyBuffer(params.limitBalanceBufferFactor);
1668
+ }
1669
+ return budget;
1670
+ }
1671
+
1012
1672
  // ─── State Manager ─────────────────────────────────────────────────────────────
1013
1673
 
1014
1674
  /**
@@ -1106,7 +1766,7 @@ export class ExtendedSVKVesuStateManager {
1106
1766
  vesuAllocationUsd: safeUsdcWeb3Number(0),
1107
1767
  extendedAllocationUsd: safeUsdcWeb3Number(0),
1108
1768
  bringLiquidityAmount: safeUsdcWeb3Number(0),
1109
- pendingDeposit: this._budget.extendedBalance?.pendingDeposit ?? new Web3Number(0, USDC_TOKEN_DECIMALS),
1769
+ pendingDeposit: safeUsdcWeb3Number(0),
1110
1770
  };
1111
1771
 
1112
1772
  this._logSolveResult(result);
@@ -1125,32 +1785,58 @@ export class ExtendedSVKVesuStateManager {
1125
1785
  logger.info(`${this._tag}::_refresh starting`);
1126
1786
 
1127
1787
  const [
1128
- vaultAllocatorBalance,
1788
+ vaultAssetBalance,
1789
+ vaultUsdcBalance,
1129
1790
  walletBalance,
1130
1791
  vesuPoolStates,
1131
1792
  extendedBalance,
1132
1793
  extendedPositions,
1133
1794
  ] = await Promise.all([
1134
- this._fetchVaultAllocatorBalance(),
1795
+ this._fetchVaultAllocatorAssetBalance(),
1796
+ this._fetchVaultAllocatorUsdcBalanceIfDistinct(),
1135
1797
  this._fetchWalletBalances(),
1136
1798
  this._fetchAllVesuPoolStates(),
1137
1799
  this._fetchExtendedBalance(),
1138
1800
  this._fetchExtendedPositions(),
1139
1801
  ]);
1140
1802
 
1141
- logger.verbose(`_refresh vaultAllocatorBalance: ${vaultAllocatorBalance.usdValue}, walletBalance: ${walletBalance.usdValue}`);
1803
+ logger.verbose(
1804
+ `${this._tag}::_refresh VA asset ${vaultAssetBalance.token.symbol}=$${vaultAssetBalance.usdValue.toFixed(2)}` +
1805
+ `${vaultUsdcBalance ? `, VA USDC=$${vaultUsdcBalance.usdValue.toFixed(2)}` : ""}` +
1806
+ `, wallet=${walletBalance.usdValue}`,
1807
+ );
1142
1808
  const unusedBalance = this._computeUnusedBalances(
1143
- vaultAllocatorBalance,
1809
+ vaultAssetBalance,
1810
+ vaultUsdcBalance,
1144
1811
  walletBalance,
1145
1812
  );
1146
1813
 
1147
- this._budget = new SolveBudget({
1148
- limitBalanceBufferFactor: this._config.limitBalanceBufferFactor,
1814
+ this._budget = createSolveBudgetFromRawState({
1815
+ assetToken: this._config.assetToken,
1816
+ usdcToken: this._config.usdcToken,
1149
1817
  unusedBalance,
1150
1818
  walletBalance,
1151
- vaultBalance: vaultAllocatorBalance,
1819
+ vaultAssetBalance,
1820
+ vaultUsdcBalance,
1152
1821
  extendedPositions,
1153
- extendedBalance,
1822
+ extendedBalance: {
1823
+ availableForTrade:
1824
+ extendedBalance?.availableForTrade ||
1825
+ new Web3Number(0, USDC_TOKEN_DECIMALS),
1826
+ availableForWithdrawal:
1827
+ extendedBalance?.availableForWithdrawal ||
1828
+ new Web3Number(0, USDC_TOKEN_DECIMALS),
1829
+ unrealisedPnl:
1830
+ extendedBalance?.unrealisedPnl ||
1831
+ new Web3Number(0, USDC_TOKEN_DECIMALS),
1832
+ balance:
1833
+ extendedBalance?.balance || new Web3Number(0, USDC_TOKEN_DECIMALS),
1834
+ equity:
1835
+ extendedBalance?.equity || new Web3Number(0, USDC_TOKEN_DECIMALS),
1836
+ pendingDeposit:
1837
+ extendedBalance?.pendingDeposit ||
1838
+ new Web3Number(0, USDC_TOKEN_DECIMALS),
1839
+ },
1154
1840
  vesuPoolStates,
1155
1841
  });
1156
1842
 
@@ -1175,10 +1861,15 @@ export class ExtendedSVKVesuStateManager {
1175
1861
 
1176
1862
  // todo add communication check with python server of extended. if not working, throw error in solve function.
1177
1863
 
1864
+ /** True when strategy asset and USDC share one token — VA USDC slot is unused (all in asset balance). */
1865
+ private _vaultAssetAndUsdcAreSameToken(): boolean {
1866
+ return this._config.assetToken.address.eq(this._config.usdcToken.address);
1867
+ }
1868
+
1178
1869
  /**
1179
- * Reads the asset-token balance sitting idle in the vault allocator contract.
1870
+ * Reads the {@link StateManagerConfig.assetToken} balance idle in the vault allocator.
1180
1871
  */
1181
- private async _fetchVaultAllocatorBalance(): Promise<TokenBalance> {
1872
+ private async _fetchVaultAllocatorAssetBalance(): Promise<TokenBalance> {
1182
1873
  const { assetToken, vaultAllocator, networkConfig, pricer } = this._config;
1183
1874
  const balance = await new ERC20(networkConfig).balanceOf(
1184
1875
  assetToken.address,
@@ -1193,25 +1884,48 @@ export class ExtendedSVKVesuStateManager {
1193
1884
  }
1194
1885
 
1195
1886
  /**
1196
- * Merges the vault-allocator balance and wallet balances into a
1197
- * deduplicated array of TokenBalance entries keyed by token address.
1198
- *
1199
- * e.g. VA has USDC, wallet has USDC + USDC.e → returns
1200
- * [{ token: USDC, amount: VA+wallet, usdValue: … },
1201
- * { token: USDC.e, amount: wallet, usdValue: … }]
1887
+ * Reads {@link StateManagerConfig.usdcToken} in the vault allocator when it differs from
1888
+ * {@link StateManagerConfig.assetToken}. Otherwise returns null (treat VA USDC as 0; stablecoin is only under asset).
1889
+ */
1890
+ private async _fetchVaultAllocatorUsdcBalanceIfDistinct(): Promise<TokenBalance | null> {
1891
+ if (this._vaultAssetAndUsdcAreSameToken()) return null;
1892
+
1893
+ const { usdcToken, vaultAllocator, networkConfig, pricer } = this._config;
1894
+ const balance = await new ERC20(networkConfig).balanceOf(
1895
+ usdcToken.address,
1896
+ vaultAllocator,
1897
+ usdcToken.decimals,
1898
+ );
1899
+ const tokenPrice = await pricer.getPrice(
1900
+ usdcToken.priceProxySymbol || usdcToken.symbol,
1901
+ );
1902
+ const usdValue =
1903
+ Number(balance.toFixed(usdcToken.decimals)) * tokenPrice.price;
1904
+
1905
+ return { token: usdcToken, amount: balance, usdValue };
1906
+ }
1907
+
1908
+ /**
1909
+ * Merges vault-allocator asset, optional vault-allocator USDC, and operator wallet
1910
+ * balances into entries keyed by token address.
1202
1911
  */
1203
1912
  private _computeUnusedBalances(
1204
- vaultAllocatorBalance: TokenBalance,
1913
+ vaultAssetBalance: TokenBalance,
1914
+ vaultUsdcBalance: TokenBalance | null,
1205
1915
  walletBalance: TokenBalance,
1206
1916
  ): TokenBalance[] {
1207
1917
  const balanceMap = new Map<string, TokenBalance>();
1208
1918
 
1209
- // Seed with vault-allocator balance
1210
- balanceMap.set(vaultAllocatorBalance.token.address.toString(), {
1211
- token: vaultAllocatorBalance.token,
1212
- amount: vaultAllocatorBalance.amount,
1213
- usdValue: vaultAllocatorBalance.usdValue,
1214
- });
1919
+ const put = (tb: TokenBalance) => {
1920
+ balanceMap.set(tb.token.address.toString(), {
1921
+ token: tb.token,
1922
+ amount: tb.amount,
1923
+ usdValue: tb.usdValue,
1924
+ });
1925
+ };
1926
+
1927
+ put(vaultAssetBalance);
1928
+ if (vaultUsdcBalance) put(vaultUsdcBalance);
1215
1929
 
1216
1930
  // Merge wallet balances by token address
1217
1931
  const key = walletBalance.token.address.toString();
@@ -1234,34 +1948,33 @@ export class ExtendedSVKVesuStateManager {
1234
1948
  }
1235
1949
 
1236
1950
  /**
1237
- * Reads the operator wallet's balances for the asset token (USDC.e) and
1238
- * USDC.e (needed for route computation P1 vs P2 decision for Extended deposits).
1951
+ * Reads the operator wallet balance for {@link StateManagerConfig.usdcToken} only
1952
+ * (wallet stablecoin is always USDC, regardless of strategy {@link StateManagerConfig.assetToken}).
1239
1953
  */
1240
1954
  private async _fetchWalletBalances(): Promise<TokenBalance> {
1241
1955
  const {
1242
1956
  networkConfig,
1243
1957
  pricer,
1244
1958
  walletAddress,
1245
- assetToken,
1246
- usdceToken,
1959
+ usdcToken,
1247
1960
  } = this._config;
1248
1961
  const erc20 = new ERC20(networkConfig);
1249
1962
 
1250
- const [usdceBalance, usdcePrice] =
1963
+ const [balance, tokenPrice] =
1251
1964
  await Promise.all([
1252
1965
  erc20.balanceOf(
1253
- usdceToken.address,
1966
+ usdcToken.address,
1254
1967
  walletAddress,
1255
- usdceToken.decimals,
1968
+ usdcToken.decimals,
1256
1969
  ),
1257
- pricer.getPrice(usdceToken.priceProxySymbol || usdceToken.symbol),
1970
+ pricer.getPrice(usdcToken.priceProxySymbol || usdcToken.symbol),
1258
1971
  ]);
1259
1972
 
1260
1973
  return {
1261
- token: usdceToken,
1262
- amount: usdceBalance,
1974
+ token: usdcToken,
1975
+ amount: balance,
1263
1976
  usdValue:
1264
- Number(usdceBalance.toFixed(usdceToken.decimals)) * usdcePrice.price,
1977
+ Number(balance.toFixed(usdcToken.decimals)) * tokenPrice.price,
1265
1978
  };
1266
1979
  }
1267
1980
 
@@ -1390,12 +2103,12 @@ export class ExtendedSVKVesuStateManager {
1390
2103
  * finite, sensible values. Throws on invalid state.
1391
2104
  */
1392
2105
  private _validateRefreshedState(): void {
1393
- if (this._budget.unusedBalance.length === 0) {
2106
+ if (this._budget.unusedBalanceRows.length === 0) {
1394
2107
  throw new Error(
1395
2108
  `${this._tag}: unusedBalance is empty after refresh`,
1396
2109
  );
1397
2110
  }
1398
- for (const balance of this._budget.unusedBalance) {
2111
+ for (const balance of this._budget.unusedBalanceRows) {
1399
2112
  this._validateTokenBalanceOrThrow(
1400
2113
  balance,
1401
2114
  `unusedBalance[${balance.token.symbol}]`,
@@ -1420,7 +2133,7 @@ export class ExtendedSVKVesuStateManager {
1420
2133
  }
1421
2134
 
1422
2135
  private _validateVesuPoolPricesOrThrow(): void {
1423
- for (const pool of this._budget.vesuPoolStates) {
2136
+ for (const pool of this._budget.vesuPools) {
1424
2137
  const poolLabel = pool.poolId.shortString();
1425
2138
  this._assertPositiveFinite(
1426
2139
  pool.collateralPrice,
@@ -1434,9 +2147,9 @@ export class ExtendedSVKVesuStateManager {
1434
2147
  }
1435
2148
 
1436
2149
  private _validateExtendedBalanceOrThrow(): void {
1437
- if (!this._budget.extendedBalance) return; // null is acceptable; treated as zero
2150
+ if (!this._budget.extendedBalanceView) return; // null is acceptable; treated as zero
1438
2151
 
1439
- const { equity, availableForTrade } = this._budget.extendedBalance;
2152
+ const { equity, availableForTrade } = this._budget.extendedBalanceView;
1440
2153
  if (
1441
2154
  !Number.isFinite(equity.toNumber()) ||
1442
2155
  !Number.isFinite(availableForTrade.toNumber())
@@ -1475,7 +2188,7 @@ export class ExtendedSVKVesuStateManager {
1475
2188
  * vault allocator to execute bringLiquidity.
1476
2189
  */
1477
2190
  private _computeDistributableAmount(
1478
- perPoolDebtDeltasToBorrow: Web3Number[],
2191
+ perPoolDebtDeltasToBorrow: readonly Web3Number[],
1479
2192
  withdrawAmount: Web3Number,
1480
2193
  ): Web3Number {
1481
2194
  const totalInvestable = this._computeTotalInvestableAmount();
@@ -1490,27 +2203,34 @@ export class ExtendedSVKVesuStateManager {
1490
2203
  }
1491
2204
 
1492
2205
  /**
1493
- * Total investable = vault allocator balance + Extended available-for-trade,
1494
- * both reduced by the configured buffer percentage.
2206
+ * Total investable = vault allocator balance + Extended available-for-trade +
2207
+ * buffered unrealised PnL, matching `deposit_cases_extended_vesu.xlsx`:
2208
+ * `(VA + wallet + EXT_WITH_AVL + EXT_UPNL) * (1 − buffer)`.
2209
+ * Positive {@link ExtendedBalanceState.pendingDeposit} stays on the afT leg only (see {@link SolveBudget.extAvailTrade}).
1495
2210
  */
1496
2211
  private _computeTotalInvestableAmount(): Web3Number {
1497
- const totalUnusedUsd = this._budget.unusedBalance.reduce(
1498
- (acc, b) => acc + b.usdValue,
1499
- 0,
1500
- );
2212
+ const totalUnusedUsd = this._budget.unusedBalancesBufferedUsdSum;
1501
2213
  logger.debug(
1502
2214
  `${this._tag}::_computeTotalInvestableAmount unusedBalances=` +
1503
- `${JSON.stringify(this._budget.unusedBalance.map((b) => ({ token: b.token.symbol, amount: b.amount.toNumber(), usdValue: b.usdValue })))}`,
2215
+ `${JSON.stringify(this._budget.unusedBalanceRows.map((b) => ({ token: b.token.symbol, amount: b.amount.toNumber(), usdValue: b.usdValue })))}`,
2216
+ );
2217
+ const extBal = this._budget.extendedBalanceView;
2218
+ const rawAft = extBal?.availableForWithdrawal?.toNumber() ?? 0;
2219
+ const rawUpnl = extBal?.unrealisedPnl?.toNumber() ?? 0;
2220
+ let extBuffered =
2221
+ this._budget.bufferedUsd(rawAft) + this._budget.bufferedUsd(rawUpnl);
2222
+ const pd = extBal?.pendingDeposit?.toNumber() ?? 0;
2223
+ if (pd > 0) extBuffered += pd;
2224
+ const extendedAvailable = new Web3Number(
2225
+ extBuffered.toFixed(USDC_TOKEN_DECIMALS),
2226
+ USDC_TOKEN_DECIMALS,
1504
2227
  );
1505
- const extendedAvailable =
1506
- this._budget.extendedBalance?.availableForTrade ??
1507
- new Web3Number(0, USDC_TOKEN_DECIMALS);
1508
2228
  logger.verbose(`_computeTotalInvestableAmount totalUnusedUsd: ${totalUnusedUsd}, extendedAvailable: ${extendedAvailable.toNumber()}`);
1509
2229
  return new Web3Number(totalUnusedUsd.toFixed(USDC_TOKEN_DECIMALS), USDC_TOKEN_DECIMALS)
1510
2230
  .plus(extendedAvailable)
1511
2231
  }
1512
2232
 
1513
- private _sumDebtDeltas(deltas: Web3Number[]): Web3Number {
2233
+ private _sumDebtDeltas(deltas: readonly Web3Number[]): Web3Number {
1514
2234
  return deltas.reduce(
1515
2235
  (sum, delta) => sum.plus(delta),
1516
2236
  new Web3Number(0, USDC_TOKEN_DECIMALS),
@@ -1548,7 +2268,7 @@ export class ExtendedSVKVesuStateManager {
1548
2268
  }
1549
2269
 
1550
2270
  // get long/short exposures on both sides
1551
- const collateralPrice = this._budget.vesuPoolStates[0]?.collateralPrice ?? 0;
2271
+ const collateralPrice = this._budget.vesuPools[0]?.collateralPrice ?? 0;
1552
2272
  const totalVesuExposureUsd = this._totalVesuCollateralUsd().plus(new Web3Number((deltaVesuCollateral * collateralPrice).toFixed(6), USDC_TOKEN_DECIMALS));
1553
2273
  const totalExtendedExposureUsd = this._totalExtendedExposureUsd().plus(new Web3Number((deltaExtendedCollateral * collateralPrice).toFixed(6), USDC_TOKEN_DECIMALS));
1554
2274
 
@@ -1569,8 +2289,10 @@ export class ExtendedSVKVesuStateManager {
1569
2289
  );
1570
2290
 
1571
2291
  // add debt repayments to vesu allocation (-1 to convert to repay amount)
1572
- const perPoolDebtDeltasToBorrow = this._budget.vesuPerPoolDebtDeltasToBorrow;
1573
- vesuAllocationUsd = vesuAllocationUsd.plus(this._sumDebtDeltas(perPoolDebtDeltasToBorrow).multipliedBy(-1));
2292
+ // const perPoolDebtDeltasToBorrow = this._budget.vesuDebtDeltas;
2293
+ // const debtDeltasSum = this._sumDebtDeltas(perPoolDebtDeltasToBorrow);
2294
+ // if (debtDeltasSum.toNumber() > 0) {
2295
+ // vesuAllocationUsd = vesuAllocationUsd.plus(this._sumDebtDeltas(perPoolDebtDeltasToBorrow).multipliedBy(-1));
1574
2296
 
1575
2297
  let vesuPositionDelta = Number((new Web3Number((vesuAllocationUsd.toNumber() * (vesuLeverage) / collateralPrice).toFixed(6), 6)).toFixedRoundDown(COLLATERAL_PRECISION));
1576
2298
  let extendedPositionDelta = Number((new Web3Number((extendedAllocationUsd.toNumber() * (extendedLeverage) / collateralPrice).toFixed(6), 6)).toFixedRoundDown(COLLATERAL_PRECISION));
@@ -1599,19 +2321,23 @@ export class ExtendedSVKVesuStateManager {
1599
2321
  * token units.
1600
2322
  */
1601
2323
  private _computePerPoolCollateralDeltas(
1602
- vesuAllocationUsd: Web3Number, perPoolDebtDeltasToBorrow: Web3Number[]
2324
+ vesuAllocationUsd: Web3Number
1603
2325
  ): VesuPoolDelta[] {
1604
2326
  const vesuLeverage = calculateVesuLeverage();
1605
2327
 
1606
- // remove any repayments from the vesu allocation
1607
- const availableVesuCollateralAllocationUsd = vesuAllocationUsd.plus(this._sumDebtDeltas(perPoolDebtDeltasToBorrow));
2328
+ const availableVesuCollateralAllocationUsd = vesuAllocationUsd;
1608
2329
  const postLeverageAllocationUsd = availableVesuCollateralAllocationUsd.multipliedBy(vesuLeverage);
1609
2330
  const totalCollateralExisting = this._totalVesuCollateral();
1610
2331
 
1611
- return this._budget.vesuPoolStates.map((pool, index) => {
2332
+ return this._budget.vesuPools.map((pool, index) => {
1612
2333
  const _postLeverageAllocation = postLeverageAllocationUsd.dividedBy(pool.collateralPrice);
1613
2334
 
1614
- const postLeverageAllocation = new Web3Number(((_postLeverageAllocation.plus(totalCollateralExisting)).toFixed(COLLATERAL_PRECISION)), pool.collateralToken.decimals).minus(totalCollateralExisting);
2335
+ const postLeverageAllocation = new Web3Number((
2336
+ (
2337
+ _postLeverageAllocation.plus(totalCollateralExisting)
2338
+ ).toFixedRoundDown(COLLATERAL_PRECISION)
2339
+ ), pool.collateralToken.decimals
2340
+ ).minus(totalCollateralExisting);
1615
2341
  const _poolCollateralDelta = this._computePoolCollateralShare(
1616
2342
  pool,
1617
2343
  totalCollateralExisting,
@@ -1623,12 +2349,12 @@ export class ExtendedSVKVesuStateManager {
1623
2349
  );
1624
2350
 
1625
2351
  // the excess collateral should come from debt.
1626
- const newDebt = postLeverageAllocationUsd.minus(availableVesuCollateralAllocationUsd).dividedBy(pool.debtPrice);
2352
+ const newDebt = (postLeverageAllocation.multipliedBy(pool.collateralPrice)).minus(availableVesuCollateralAllocationUsd).dividedBy(pool.debtPrice);
1627
2353
  return {
1628
2354
  poolId: pool.poolId,
1629
2355
  collateralToken: pool.collateralToken,
1630
2356
  debtToken: pool.debtToken,
1631
- debtDelta: perPoolDebtDeltasToBorrow[index].plus(newDebt),
2357
+ debtDelta: newDebt,
1632
2358
  collateralDelta: poolCollateralDelta,
1633
2359
  collateralPrice: pool.collateralPrice,
1634
2360
  debtPrice: pool.debtPrice,
@@ -1647,7 +2373,7 @@ export class ExtendedSVKVesuStateManager {
1647
2373
  totalVesuAllocation: Web3Number,
1648
2374
  ): Web3Number {
1649
2375
  const isSinglePoolOrZeroTotal =
1650
- this._budget.vesuPoolStates.length === 1 ||
2376
+ this._budget.vesuPools.length === 1 ||
1651
2377
  totalCollateral.toNumber() === 0;
1652
2378
 
1653
2379
  if (isSinglePoolOrZeroTotal) return totalVesuAllocation;
@@ -1703,8 +2429,8 @@ export class ExtendedSVKVesuStateManager {
1703
2429
  ): Web3Number {
1704
2430
  let totalExposureCollateral = new Web3Number(0, USDC_TOKEN_DECIMALS); // just some decimals is ok
1705
2431
 
1706
- for (let i = 0; i < this._budget.vesuPoolStates.length; i++) {
1707
- const pool = this._budget.vesuPoolStates[i];
2432
+ for (let i = 0; i < this._budget.vesuPools.length; i++) {
2433
+ const pool = this._budget.vesuPools[i];
1708
2434
  const delta = vesuDeltas[i];
1709
2435
  logger.debug(
1710
2436
  `${this._tag}::_computeTargetExtendedExposure poolId=${pool.poolId.toString()}, collateralAmount=${pool.collateralAmount.toNumber()}, collateralDelta=${delta.collateralDelta.toNumber()}`,
@@ -1724,7 +2450,7 @@ export class ExtendedSVKVesuStateManager {
1724
2450
  }
1725
2451
 
1726
2452
  private _hasNoExtendedPositions(): boolean {
1727
- return this._budget.extendedPositions.length === 0;
2453
+ return this._budget.extendedPositionsView.length === 0;
1728
2454
  }
1729
2455
 
1730
2456
  /**
@@ -1738,7 +2464,7 @@ export class ExtendedSVKVesuStateManager {
1738
2464
  {
1739
2465
  instrument: this._config.extendedAdapter.config.extendedMarketName,
1740
2466
  delta: new Web3Number(
1741
- delta.toFixed(COLLATERAL_PRECISION),
2467
+ delta.toFixedRoundDown(COLLATERAL_PRECISION),
1742
2468
  USDC_TOKEN_DECIMALS,
1743
2469
  ),
1744
2470
  },
@@ -1754,7 +2480,7 @@ export class ExtendedSVKVesuStateManager {
1754
2480
  ): ExtendedPositionDelta[] {
1755
2481
  const totalExposure = this._totalExtendedExposure();
1756
2482
 
1757
- return this._budget.extendedPositions.map((position) => {
2483
+ return this._budget.extendedPositionsView.map((position) => {
1758
2484
  const share = this._positionExposureShareFraction(
1759
2485
  position,
1760
2486
  totalExposure,
@@ -1762,7 +2488,7 @@ export class ExtendedSVKVesuStateManager {
1762
2488
  return {
1763
2489
  instrument: position.instrument,
1764
2490
  delta: new Web3Number(
1765
- totalDelta.multipliedBy(share).toFixed(COLLATERAL_PRECISION),
2491
+ totalDelta.multipliedBy(share).toFixedRoundDown(COLLATERAL_PRECISION),
1766
2492
  USDC_TOKEN_DECIMALS,
1767
2493
  ),
1768
2494
  };
@@ -1780,7 +2506,7 @@ export class ExtendedSVKVesuStateManager {
1780
2506
  ): number {
1781
2507
  const isSingleOrZero =
1782
2508
  totalExposure.toNumber() === 0 ||
1783
- this._budget.extendedPositions.length === 1;
2509
+ this._budget.extendedPositionsView.length === 1;
1784
2510
  if (isSingleOrZero) return 1;
1785
2511
  return position.valueUsd.dividedBy(totalExposure).toNumber();
1786
2512
  }
@@ -1796,9 +2522,10 @@ export class ExtendedSVKVesuStateManager {
1796
2522
  private _computeExtendedDepositDelta(
1797
2523
  extendedAllocationUsd: Web3Number,
1798
2524
  ): Web3Number {
1799
- const currentAvailableForTrade =
1800
- this._budget.extendedBalance?.availableForTrade ??
1801
- new Web3Number(0, USDC_TOKEN_DECIMALS);
2525
+ const currentAvailableForTrade = new Web3Number(
2526
+ this._budget.extAvailTrade.toFixed(USDC_TOKEN_DECIMALS),
2527
+ USDC_TOKEN_DECIMALS,
2528
+ );
1802
2529
 
1803
2530
  return new Web3Number(
1804
2531
  extendedAllocationUsd
@@ -1810,8 +2537,8 @@ export class ExtendedSVKVesuStateManager {
1810
2537
 
1811
2538
  private _computeVesuDepositAmount(vesuDeltas: VesuPoolDelta[]): Web3Number {
1812
2539
  let totalVesuCollateral = new Web3Number(0, USDC_TOKEN_DECIMALS); // just some decimals is ok
1813
- for (let i = 0; i < this._budget.vesuPoolStates.length; i++) {
1814
- const pool = this._budget.vesuPoolStates[i];
2540
+ for (let i = 0; i < this._budget.vesuPools.length; i++) {
2541
+ const pool = this._budget.vesuPools[i];
1815
2542
  const delta = vesuDeltas[i];
1816
2543
  totalVesuCollateral = totalVesuCollateral.plus(delta.collateralDelta.multipliedBy(pool.collateralPrice));
1817
2544
  totalVesuCollateral = totalVesuCollateral.minus(delta.debtDelta.multipliedBy(pool.debtPrice));
@@ -1868,15 +2595,23 @@ export class ExtendedSVKVesuStateManager {
1868
2595
  routes.push({ type: RouteType.VESU_REPAY as const, ...route, priority: routes.length });
1869
2596
  }
1870
2597
 
1871
- this._budget.spendVA(used);
2598
+ this._budget.spendVaRawUsd(used);
1872
2599
  }
1873
2600
 
1874
- private _buildVesuBorrowRoutes(totalUsd: number, routes: ExecutionRoute[]): { routes: ExecutionRoute[], remaining: number } {
2601
+ private _buildVesuBorrowRoutes(
2602
+ totalUsd: number,
2603
+ routes: ExecutionRoute[],
2604
+ opts?: { maxBorrowUsd?: number },
2605
+ ): { routes: ExecutionRoute[], remaining: number } {
1875
2606
  let borrowable = this._budget.vesuBorrowCapacity;
2607
+ if (opts?.maxBorrowUsd !== undefined) {
2608
+ borrowable = Math.min(borrowable, Math.max(0, opts.maxBorrowUsd));
2609
+ }
1876
2610
  if (totalUsd <= CASE_THRESHOLD_USD) return { routes, remaining: totalUsd };
1877
2611
  if (borrowable <= CASE_THRESHOLD_USD) return { routes, remaining: totalUsd };
1878
2612
 
1879
- const { used, spendsByPool } = this._budget.spendVesuBorrowCapacity(totalUsd);
2613
+ const borrowTarget = Math.min(totalUsd, borrowable);
2614
+ const { used, spendsByPool } = this._budget.spendVesuBorrowCapacity(borrowTarget);
1880
2615
  for (const route of spendsByPool) {
1881
2616
  routes.push({ type: RouteType.VESU_BORROW as const, ...route, priority: routes.length });
1882
2617
  }
@@ -1937,9 +2672,9 @@ export class ExtendedSVKVesuStateManager {
1937
2672
  // }
1938
2673
 
1939
2674
  private _getWalletToVARoute(tryAmount: number, routes: ExecutionRoute[]): { routes: ExecutionRoute[], remaining: number } {
1940
- const usableAmount = Math.min(tryAmount, this._budget.walletUsd);
1941
- if (usableAmount > CASE_THRESHOLD_USD) {
1942
- const walletUsed = this._budget.spendWallet(usableAmount);
2675
+ const usableRaw = Math.min(tryAmount, this._budget.walletUsd);
2676
+ if (usableRaw > CASE_THRESHOLD_USD) {
2677
+ const walletUsed = this._budget.spendWallet(usableRaw);
1943
2678
  this._budget.addToVA(walletUsed);
1944
2679
  const route: TransferRoute = { type: RouteType.WALLET_TO_VA, amount: safeUsdcWeb3Number(walletUsed), priority: routes.length };
1945
2680
  routes.push(route);
@@ -1949,9 +2684,9 @@ export class ExtendedSVKVesuStateManager {
1949
2684
  }
1950
2685
 
1951
2686
  private _getWalletToEXTENDEDRoute(tryAmount: number, routes: ExecutionRoute[], shouldAddWaitRoute = true): { routes: ExecutionRoute[], remaining: number } {
1952
- const usableAmount = Math.min(tryAmount, this._budget.walletUsd);
1953
- if (usableAmount > CASE_THRESHOLD_USD) {
1954
- const walletUsed = this._budget.spendWallet(usableAmount);
2687
+ const usableRaw = Math.min(tryAmount, this._budget.walletUsd);
2688
+ if (usableRaw > CASE_THRESHOLD_USD) {
2689
+ const walletUsed = this._budget.spendWallet(usableRaw);
1955
2690
  this._budget.addToExtAvailTrade(walletUsed);
1956
2691
  routes.push({ type: RouteType.WALLET_TO_EXTENDED, amount: safeUsdcWeb3Number(walletUsed), priority: routes.length } as TransferRoute);
1957
2692
 
@@ -1964,9 +2699,9 @@ export class ExtendedSVKVesuStateManager {
1964
2699
  }
1965
2700
 
1966
2701
  private _getVAToEXTENDEDRoute(tryAmount: number, routes: ExecutionRoute[], shouldAddWaitRoute = true): { routes: ExecutionRoute[], remaining: number } {
1967
- const usableAmount = Math.min(tryAmount, this._budget.vaUsd);
1968
- if (usableAmount > CASE_THRESHOLD_USD) {
1969
- const vaUsed = this._budget.spendVA(usableAmount);
2702
+ const usable = Math.min(tryAmount, this._budget.vaUsd);
2703
+ if (usable > CASE_THRESHOLD_USD) {
2704
+ const vaUsed = this._budget.spendVA(usable);
1970
2705
  this._budget.addToExtAvailTrade(vaUsed);
1971
2706
 
1972
2707
  // add extended deposit route
@@ -1984,24 +2719,25 @@ export class ExtendedSVKVesuStateManager {
1984
2719
 
1985
2720
  private _getExtendedToWalletRoute(tryAmount: number, routes: ExecutionRoute[], shouldAddWaitRoute = true): { routes: ExecutionRoute[], remaining: number } {
1986
2721
  if (tryAmount <= CASE_THRESHOLD_USD) return { routes, remaining: tryAmount };
1987
- assert(tryAmount <= this._budget.extAvailWithdraw, `tryAmount is greater than extAvailTrade, tryAmount: ${tryAmount}, extAvailWithdraw: ${this._budget.extAvailWithdraw}`);
1988
- const extWithdrawUsed = this._budget.spendExtAvailTrade(tryAmount);
1989
- this._budget.addToWallet(Math.abs(extWithdrawUsed));
1990
- const route: TransferRoute = { type: RouteType.EXTENDED_TO_WALLET, amount: safeUsdcWeb3Number(Math.abs(extWithdrawUsed)), priority: routes.length };
2722
+ const rawCap = this._budget.extAvailWithdraw + this._budget.extAvailUpnl;
2723
+ const rawSpend = Math.min(tryAmount, rawCap);
2724
+ if (rawSpend <= CASE_THRESHOLD_USD) return { routes, remaining: tryAmount };
2725
+ const rawOut = this._budget.spendExtAvailTrade(rawSpend);
2726
+ this._budget.addToWallet(Math.abs(rawOut));
2727
+ const route: TransferRoute = { type: RouteType.EXTENDED_TO_WALLET, amount: safeUsdcWeb3Number(rawSpend), priority: routes.length };
1991
2728
  routes.push(route);
1992
2729
 
1993
2730
  if (shouldAddWaitRoute) {
1994
2731
  routes.push({ type: RouteType.RETURN_TO_WAIT, priority: routes.length });
1995
2732
  }
1996
- return { routes, remaining: tryAmount - Math.abs(extWithdrawUsed) };
2733
+ return { routes, remaining: tryAmount - rawSpend };
1997
2734
  }
1998
2735
 
1999
2736
  private _getWALLETToVARoute(tryAmount: number, routes: ExecutionRoute[]): { routes: ExecutionRoute[], remaining: number } {
2000
2737
  if (tryAmount <= CASE_THRESHOLD_USD) return { routes, remaining: tryAmount };
2001
- const usableAmount = Math.min(tryAmount, this._budget.walletUsd);
2002
- if (usableAmount <= CASE_THRESHOLD_USD) return { routes, remaining: tryAmount };
2003
- // assert(tryAmount <= this._budget.walletUsd, `tryAmount is greater than walletUsd, tryAmount: ${tryAmount}, walletUsd: ${this._budget.walletUsd}`);
2004
- const walletUsed = this._budget.spendWallet(tryAmount);
2738
+ const usableRaw = Math.min(tryAmount, this._budget.walletUsd);
2739
+ if (usableRaw <= CASE_THRESHOLD_USD) return { routes, remaining: tryAmount };
2740
+ const walletUsed = this._budget.spendWallet(usableRaw);
2005
2741
  this._budget.addToVA(walletUsed);
2006
2742
  const route: TransferRoute = { type: RouteType.WALLET_TO_VA, amount: safeUsdcWeb3Number(walletUsed), priority: routes.length };
2007
2743
  routes.push(route);
@@ -2009,22 +2745,22 @@ export class ExtendedSVKVesuStateManager {
2009
2745
  }
2010
2746
 
2011
2747
  private _getUpnlRoute(tryAmount: number, routes: ExecutionRoute[]): { routes: ExecutionRoute[], remaining: number } {
2012
- const upnl = this._budget.extendedBalance?.unrealisedPnl?.toNumber() ?? 0;
2013
- const usableAmount = Math.min(tryAmount, upnl);
2014
- if (usableAmount <= CASE_THRESHOLD_USD) return { routes, remaining: tryAmount };
2748
+ const rawUpnl = this._budget.extAvailUpnl;
2749
+ const usableRaw = Math.min(tryAmount, rawUpnl);
2750
+ if (usableRaw <= 0) return { routes, remaining: tryAmount };
2015
2751
 
2016
2752
  // if fails, ensure there is a way to choose the positio to use to create realised pnl
2017
2753
  // until then, only 1 position is supported
2018
- const upnlUsed = this._budget.spendExtAvailUpnl(usableAmount); // removes from upnl
2019
- this._budget.addToExtAvailTrade(upnlUsed); // adds to available-for-withdrawal
2020
- assert(this._budget.extendedPositions.length == 1, 'SolveBudget::_getUpnlRoute: extendedPositions length must be 1');
2754
+ this._budget.spendExtAvailUpnl(usableRaw);
2755
+ this._budget.addToExtAvailTrade(usableRaw);
2756
+ assert(this._budget.extendedPositionsView.length == 1, 'SolveBudget::_getUpnlRoute: extendedPositions length must be 1');
2021
2757
  routes.push({
2022
2758
  type: RouteType.REALISE_PNL,
2023
- amount: safeUsdcWeb3Number(upnlUsed),
2024
- instrument: this._budget.extendedPositions[0].instrument,
2759
+ amount: safeUsdcWeb3Number(usableRaw),
2760
+ instrument: this._budget.extendedPositionsView[0].instrument,
2025
2761
  priority: routes.length,
2026
2762
  } as RealisePnlRoute);
2027
- return { routes, remaining: tryAmount - upnlUsed };
2763
+ return { routes, remaining: tryAmount - usableRaw };
2028
2764
  }
2029
2765
 
2030
2766
  // ── Sub-classifiers ────────────────────────────────────────────────────
@@ -2047,7 +2783,7 @@ export class ExtendedSVKVesuStateManager {
2047
2783
 
2048
2784
  // ── Step 1: VA balance ────────────────────────────────────────────────
2049
2785
  // VA funds are already in the vault allocator — no transfer route needed.
2050
- const vaUsed = this._budget.spendVA(Math.min(this._budget.vaUsd, remaining));
2786
+ const vaUsed = this._budget.spendVA(remaining);
2051
2787
  remaining -= vaUsed;
2052
2788
 
2053
2789
  let totalExtUsed = 0;
@@ -2080,62 +2816,110 @@ export class ExtendedSVKVesuStateManager {
2080
2816
  totalExtUsed = usableWithrawAmount + upnlUsed;
2081
2817
  }
2082
2818
 
2083
- // ── Step 5: Unwind positions on both sides ───────────────────────────
2084
- // Decrease Vesu lever + Extended exposure by equal BTC amounts to
2085
- // maintain delta neutrality. Freed funds are picked up next cycle.
2819
+ // ── Step 5: Unwind positions ────────────────────────────────────────
2820
+ // Handle imbalance first (unwind from larger side), then equal unwind.
2821
+ // VESU_MULTIPLY_DECREASE_LEVER handles the BTC→USDC swap internally.
2086
2822
  if (remaining > CASE_THRESHOLD_USD) {
2087
- const avgCollPrice = this._budget.vesuPoolStates[0]?.collateralPrice ?? 1;
2088
- assert(this._budget.vesuPoolStates.length == 1, 'SolveBudget::_classifyWithdrawal: vesuPoolStates length must be 1');
2089
- // below function doesnt handle multiple pools yet
2090
- const { vesuPositionDelta, extendedPositionDelta, vesuAllocationUsd, extendedAllocationUsd } = this._computeAllocationSplit(new Web3Number((-remaining).toFixed(6), USDC_TOKEN_DECIMALS));
2091
-
2092
- if (vesuPositionDelta < 0) {
2093
- const vesuAdapter = this._config.vesuAdapters[0];
2094
- const debtDelta = Math.min(0, vesuPositionDelta * avgCollPrice - vesuAllocationUsd.toNumber());
2095
- const withdrawAmount = vesuAllocationUsd.dividedBy(avgCollPrice);
2096
- withdrawAmount.decimals = vesuAdapter.config.collateral.decimals;
2823
+ assert(this._budget.vesuPools.length == 1, 'SolveBudget::_classifyWithdrawal: vesuPoolStates length must be 1');
2824
+ const vesuAdapter = this._config.vesuAdapters[0];
2825
+ const avgCollPrice = this._budget.vesuPools[0]?.collateralPrice ?? 1;
2826
+ const vesuLeverage = calculateVesuLeverage();
2827
+ const extLeverage = calculateExtendedLevergae();
2828
+ const freedPerBtcVesu = avgCollPrice / vesuLeverage;
2829
+ const freedPerBtcExt = avgCollPrice / extLeverage;
2830
+
2831
+ const vesuColBtc = this._budget.vesuPools[0].collateralAmount.toNumber();
2832
+ const extPosBtc = this._totalExtendedExposure().toNumber();
2833
+ let stillNeeded = remaining;
2834
+ let vesuBtcDelta = 0;
2835
+ let extBtcDelta = 0;
2836
+ let extFreed = 0;
2837
+
2838
+ const roundUpBtc = (x: number): number => {
2839
+ const factor = 10 ** COLLATERAL_PRECISION;
2840
+ return Math.ceil(x * factor) / factor;
2841
+ };
2842
+
2843
+ // Step 5a: Handle imbalance
2844
+ const diff = vesuColBtc - extPosBtc;
2845
+ let currentVesuBtc = vesuColBtc;
2846
+ let currentExtBtc = extPosBtc;
2847
+
2848
+ if (Math.abs(diff) > 1e-8) {
2849
+ if (diff > 0) {
2850
+ const btcRaw = stillNeeded / freedPerBtcVesu;
2851
+ const btc = Math.min(roundUpBtc(Math.min(Math.abs(diff), btcRaw, currentVesuBtc)), currentVesuBtc);
2852
+ vesuBtcDelta += btc;
2853
+ stillNeeded -= btc * freedPerBtcVesu;
2854
+ currentVesuBtc -= btc;
2855
+ } else {
2856
+ const btcRaw = stillNeeded / freedPerBtcExt;
2857
+ const btc = Math.min(roundUpBtc(Math.min(Math.abs(diff), btcRaw, currentExtBtc)), currentExtBtc);
2858
+ extBtcDelta += btc;
2859
+ extFreed += btc * freedPerBtcExt;
2860
+ stillNeeded -= btc * freedPerBtcExt;
2861
+ currentExtBtc -= btc;
2862
+ }
2863
+ }
2864
+
2865
+ // Step 5b: Equal unwind from both sides
2866
+ if (stillNeeded > CASE_THRESHOLD_USD) {
2867
+ const combinedFreed = freedPerBtcVesu + freedPerBtcExt;
2868
+ const maxBtc = Math.min(currentVesuBtc, currentExtBtc);
2869
+ const btcRaw = stillNeeded / combinedFreed;
2870
+ const btc = Math.min(roundUpBtc(Math.min(btcRaw, maxBtc)), maxBtc);
2871
+ vesuBtcDelta += btc;
2872
+ extBtcDelta += btc;
2873
+ extFreed += btc * freedPerBtcExt;
2874
+ }
2875
+
2876
+ const r6 = (n: number) => Number(n.toFixed(6));
2877
+
2878
+ // Emit VESU_MULTIPLY_DECREASE_LEVER (handles swap internally, freed funds go to VA)
2879
+ if (vesuBtcDelta > 0) {
2880
+ const totalVesuBtcSigned = -vesuBtcDelta;
2881
+ const targetLtv = 1 - 1 / vesuLeverage; // vesuLeverage = 1/(1-ltv), so ltv = 1-1/lev
2882
+ const debtDelta = r6(totalVesuBtcSigned * avgCollPrice * targetLtv);
2883
+ // Same convention as LTV rebalance: no separate margin BTC; full Vesu reduction is the swap leg.
2884
+ const marginBtc = 0;
2885
+ const swappedBtc = Number(vesuBtcDelta.toFixed(COLLATERAL_PRECISION));
2886
+
2097
2887
  routes.push({
2098
2888
  type: RouteType.VESU_MULTIPLY_DECREASE_LEVER,
2099
2889
  poolId: vesuAdapter.config.poolId,
2100
2890
  collateralToken: vesuAdapter.config.collateral,
2101
- marginAmount: withdrawAmount,
2102
- swappedCollateralAmount: (new Web3Number(vesuPositionDelta, vesuAdapter.config.collateral.decimals)).minus(withdrawAmount),
2891
+ marginAmount: new Web3Number(marginBtc.toFixed(COLLATERAL_PRECISION), vesuAdapter.config.collateral.decimals),
2892
+ swappedCollateralAmount: new Web3Number(swappedBtc.toFixed(COLLATERAL_PRECISION), vesuAdapter.config.collateral.decimals),
2103
2893
  debtToken: vesuAdapter.config.debt,
2104
2894
  debtAmount: new Web3Number(debtDelta, USDC_TOKEN_DECIMALS),
2105
2895
  priority: routes.length,
2106
2896
  } as VesuMultiplyRoute);
2107
- this._budget.applyVesuDelta(vesuAdapter.config.poolId, vesuAdapter.config.collateral, vesuAdapter.config.debt, new Web3Number(vesuPositionDelta, USDC_TOKEN_DECIMALS), new Web3Number(debtDelta, USDC_TOKEN_DECIMALS));
2108
-
2109
- // Swap freed BTC → USDC (exact amount determined at runtime)
2110
- if (vesuAllocationUsd.toNumber() < -CASE_THRESHOLD_USD) {
2111
- const swapAmount = new Web3Number(((vesuAllocationUsd.toNumber() / avgCollPrice) * 0.998).toFixed(6), USDC_TOKEN_DECIMALS);
2112
- routes.push({
2113
- type: RouteType.AVNU_WITHDRAW_SWAP,
2114
- fromToken: vesuAdapter.config.collateral.symbol,
2115
- // add buffer to avoid rounding errors
2116
- fromAmount: swapAmount,
2117
- toToken: vesuAdapter.config.debt.symbol,
2118
- priority: routes.length,
2119
- } as SwapRoute);
2120
- this._budget.addToVA(vesuAllocationUsd.abs().toNumber());
2121
- }
2897
+ this._budget.applyVesuDelta(
2898
+ vesuAdapter.config.poolId, vesuAdapter.config.collateral, vesuAdapter.config.debt,
2899
+ new Web3Number(r6(totalVesuBtcSigned), USDC_TOKEN_DECIMALS),
2900
+ new Web3Number(debtDelta, USDC_TOKEN_DECIMALS),
2901
+ );
2902
+ this._budget.addToVA(vesuBtcDelta * freedPerBtcVesu);
2122
2903
  }
2123
2904
 
2124
-
2125
- if (extendedPositionDelta < 0) {
2126
- // Decrease Extended exposure by the same BTC amount
2905
+ // Emit EXTENDED_DECREASE_LEVER
2906
+ if (extBtcDelta > 0) {
2127
2907
  routes.push({
2128
2908
  type: RouteType.EXTENDED_DECREASE_LEVER,
2129
- amount: safeUsdcWeb3Number(extendedPositionDelta),
2909
+ amount: safeUsdcWeb3Number(-r6(extBtcDelta)),
2130
2910
  instrument,
2131
2911
  priority: routes.length,
2132
2912
  });
2133
- this._budget.applyExtendedExposureDelta(instrument, safeUsdcWeb3Number(extendedPositionDelta));
2134
- this._budget.addToExtAvailTrade(extendedAllocationUsd.abs().toNumber());
2135
- totalExtUsed += extendedAllocationUsd.abs().toNumber();
2913
+ this._budget.applyExtendedExposureDelta(
2914
+ instrument,
2915
+ safeUsdcWeb3Number(-r6(extBtcDelta)),
2916
+ avgCollPrice,
2917
+ );
2918
+ totalExtUsed += extFreed;
2136
2919
  }
2137
2920
  }
2138
2921
 
2922
+ // Bridge extended funds to VA (availableForTrade → wallet → VA)
2139
2923
  if (totalExtUsed > CASE_THRESHOLD_USD) {
2140
2924
  this._getExtendedToWalletRoute(totalExtUsed, routes);
2141
2925
  this._getWALLETToVARoute(totalExtUsed, routes);
@@ -2169,72 +2953,275 @@ export class ExtendedSVKVesuStateManager {
2169
2953
  *
2170
2954
  * Design: accumulate all ext-to-wallet moves, add transfer routes at the end (principle #3).
2171
2955
  */
2172
- private _classifyLtvVesu(): SolveCaseEntry[] {
2173
- const debtDeltaSum = this._budget.vesuPerPoolDebtDeltasToBorrow.reduce(
2174
- (a, b) => a + b.toNumber(), 0,
2956
+ /**
2957
+ * Unified LTV classifier. Computes both Vesu repay and Extended margin needs,
2958
+ * then builds all routes in a single pass with no duplicate transfers.
2959
+ *
2960
+ * Vesu repay priority: VA > Wallet > ExtAvl > ExtUpnl
2961
+ * Extended margin priority: Wallet > VA > VesuBorrow
2962
+ * Shared sources consumed by Vesu first (higher priority).
2963
+ */
2964
+ private _classifyLTV(): SolveCaseEntry[] {
2965
+ assert(this._budget.vesuPools.length === 1, `${this._tag}::_classifyLTV expects exactly one Vesu pool`);
2966
+ const d = rebalance(this._ltvRebalanceInputsFromBudget());
2967
+ if (this._isLtvRebalanceNoop(d)) return [];
2968
+
2969
+ logger.info(
2970
+ `${this._tag}::_classifyLTV deltas extPos=${d.dExtPosition} vesuPos=${d.dVesuPosition} `
2971
+ + `vesuDebt=${d.dVesuDebt} va=${d.dVaUsd} wallet=${d.dWalletUsd} borrow=${d.dVesuBorrowCapacity} `
2972
+ + `T=${d.dTransferVesuToExt}`,
2175
2973
  );
2176
- logger.info(`${this._tag}::_classifyLtvVesu debtDeltaSum: ${debtDeltaSum}`);
2177
2974
 
2178
- // todo, even if sum is positive, but in a multi-pool situation,
2179
- // even if one bad, its problem. need to handle this.
2180
- const allHealthLTVs = this._budget.shouldVesuRebalance.every(shouldReb => !shouldReb)
2181
- if (debtDeltaSum >= -CASE_THRESHOLD_USD || allHealthLTVs) return [];
2975
+ const routes = this._buildLtvRoutesFromRebalanceDeltas(d);
2976
+ if (routes.length === 0) return [];
2182
2977
 
2183
- const needed = Math.abs(debtDeltaSum);
2184
- const routes: ExecutionRoute[] = [];
2185
- let remaining = needed;
2186
- let totalExtUsed = 0;
2187
- let caseId: CaseId = CaseId.LTV_VESU_HIGH_USE_VA_OR_WALLET;
2978
+ const amountUsd =
2979
+ Math.abs(d.dVaUsd)
2980
+ + Math.abs(d.dWalletUsd)
2981
+ + Math.abs(d.dVesuBorrowCapacity)
2982
+ + Math.abs(d.dExtAvlWithdraw)
2983
+ + Math.abs(d.dExtUpnl)
2984
+ + Math.abs(d.dTransferVesuToExt);
2188
2985
 
2189
- // ── Step 1: VA + Wallet balance ─────────────────────────────────────
2190
- // VA funds already in VA — no transfer route needed.
2191
- const vaUsed = this._budget.spendVA(Math.min(this._budget.vaUsd, remaining));
2192
- remaining -= vaUsed;
2986
+ routes.forEach((r, i) => { r.priority = i; });
2987
+ return [{
2988
+ case: CASE_DEFINITIONS[CaseId.MANAGE_LTV],
2989
+ additionalArgs: { amount: safeUsdcWeb3Number(amountUsd) },
2990
+ routes,
2991
+ }];
2992
+ }
2193
2993
 
2194
- // ── Step 2: Wallet balance → VA ──────────────────────────────────────
2195
- if (remaining > CASE_THRESHOLD_USD) {
2196
- const { remaining: walletToVaRemaining } = this._getWALLETToVARoute(remaining, routes);
2197
- remaining = walletToVaRemaining;
2198
- }
2994
+ private _ltvRebalanceInputsFromBudget() {
2995
+ const pool = this._budget.vesuPools[0];
2996
+ const instrument = this._config.extendedAdapter.config.extendedMarketName ?? 'BTC-USD';
2997
+ const extPosBtc = this._budget.extendedPositionsView
2998
+ .filter(p => p.instrument === instrument)
2999
+ .reduce((s, p) => s + Math.abs(p.size.toNumber()), 0);
3000
+ const targetHF = VesuConfig.maxLtv / VesuConfig.targetLtv;
2199
3001
 
2200
- // ── Step 3: Extended available-for-withdrawal, then uPnL ────────────
2201
- if (remaining > CASE_THRESHOLD_USD) {
2202
- const usableWithdrawAmount = Math.min(remaining, this._budget.extAvailWithdraw);
2203
- remaining -= usableWithdrawAmount;
3002
+ return {
3003
+ ext: {
3004
+ positionBtc: extPosBtc,
3005
+ equity: this._budget.extendedBalanceView?.equity?.toNumber() ?? 0,
3006
+ avlWithdraw: this._budget.extAvailWithdraw,
3007
+ upnl: this._budget.extAvailUpnl,
3008
+ leverage: calculateExtendedLevergae(),
3009
+ },
3010
+ vesu: {
3011
+ positionBtc: pool.collateralAmount.toNumber(),
3012
+ debt: pool.debtAmount.toNumber(),
3013
+ debtPrice: pool.debtPrice,
3014
+ maxLTV: VesuConfig.maxLtv,
3015
+ targetHF,
3016
+ },
3017
+ btcPrice: pool.collateralPrice,
3018
+ funding: {
3019
+ vaUsd: this._budget.vaUsd,
3020
+ walletUsd: this._budget.walletUsd,
3021
+ vesuBorrowCapacity: this._budget.vesuBorrowCapacity,
3022
+ extAvlWithdraw: this._budget.extAvailWithdraw,
3023
+ extUpnl: this._budget.extAvailUpnl,
3024
+ },
3025
+ config: {
3026
+ positionPrecision: COLLATERAL_PRECISION,
3027
+ hfBuffer: 0.05,
3028
+ },
3029
+ };
3030
+ }
2204
3031
 
2205
- let upnlUsed = 0;
2206
- if (remaining > CASE_THRESHOLD_USD) {
2207
- const { remaining: upnlRemaining } = this._getUpnlRoute(remaining, routes);
2208
- upnlUsed = remaining - upnlRemaining;
2209
- remaining = upnlRemaining;
2210
- caseId = CaseId.LTV_EXTENDED_PROFITABLE_REALIZE;
3032
+ private _isLtvRebalanceNoop(d: RebalanceDeltas): boolean {
3033
+ const btcEps = 10 ** -COLLATERAL_PRECISION;
3034
+ const usdEps = CASE_THRESHOLD_USD;
3035
+ return (
3036
+ Math.abs(d.dExtPosition) < btcEps
3037
+ && Math.abs(d.dVesuPosition) < btcEps
3038
+ && Math.abs(d.dVesuDebt) < 1e-6
3039
+ && Math.abs(d.dExtAvlWithdraw) < usdEps
3040
+ && Math.abs(d.dExtUpnl) < usdEps
3041
+ && Math.abs(d.dVaUsd) < usdEps
3042
+ && Math.abs(d.dWalletUsd) < usdEps
3043
+ && Math.abs(d.dVesuBorrowCapacity) < usdEps
3044
+ && Math.abs(d.dTransferVesuToExt) < usdEps
3045
+ );
3046
+ }
3047
+
3048
+ /**
3049
+ * Turn pure rebalance() deltas into execution routes.
3050
+ * Order: Vesu multiply (decrease/increase) → Extended lever → aggregated transfers
3051
+ * (REALISE_PNL, EXTENDED_TO_WALLET + WAIT, WALLET_TO_VA, VESU_BORROW, VESU_REPAY, VA_TO_EXTENDED).
3052
+ */
3053
+ private _buildLtvRoutesFromRebalanceDeltas(d: RebalanceDeltas): ExecutionRoute[] {
3054
+ const routes: ExecutionRoute[] = [];
3055
+ const pool = this._budget.vesuPools[0];
3056
+ const vesuAdapter = this._config.vesuAdapters[0];
3057
+ const instrument = this._config.extendedAdapter.config.extendedMarketName ?? 'BTC-USD';
3058
+ const price = pool.collateralPrice;
3059
+ const debtPrice = pool.debtPrice;
3060
+ const targetLtv = VesuConfig.targetLtv;
3061
+ const btcEps = 10 ** -COLLATERAL_PRECISION;
3062
+
3063
+ let multiplyDebtRepayUsd = 0;
3064
+
3065
+ // ── 1) Vesu lever (multiply) first ─────────────────────────────────
3066
+ if (d.dVesuPosition < -btcEps) {
3067
+ const xBtc = -d.dVesuPosition;
3068
+ // When Vesu sends USD to Extended (dTransferVesuToExt > 0), part of the BTC cut must be the
3069
+ // multiply "margin" leg so USDC lands in VA and can be routed VA_TO_EXTENDED (adapter uses sub_margin).
3070
+ const transferUsdFromVesu = Math.max(0, d.dTransferVesuToExt);
3071
+ let marginBtc = 0;
3072
+ let swappedBtc = Number(xBtc.toFixed(COLLATERAL_PRECISION));
3073
+ if (transferUsdFromVesu > CASE_THRESHOLD_USD && price > 0) {
3074
+ const marginCapFromTransfer = transferUsdFromVesu / price;
3075
+ marginBtc = Number(
3076
+ Math.min(xBtc, marginCapFromTransfer).toFixed(COLLATERAL_PRECISION),
3077
+ );
3078
+ swappedBtc = Number((xBtc - marginBtc).toFixed(COLLATERAL_PRECISION));
3079
+ }
3080
+ // Swap leg repays at most (swapped BTC notional in USD); any extra debt reduction
3081
+ // is a separate VESU_REPAY (see multiplyDebtRepayUsd / standaloneRepayUsd below).
3082
+ const swapLegMaxRepayUsd = swappedBtc * price * debtPrice;
3083
+ const debtUsdFallback = swappedBtc * price * targetLtv;
3084
+ let debtTokenDelta: number;
3085
+ if (d.dVesuDebt < 0) {
3086
+ const needRepayUsd = -d.dVesuDebt * debtPrice;
3087
+ const multiplyRepayUsd = Math.min(needRepayUsd, swapLegMaxRepayUsd);
3088
+ debtTokenDelta = -(multiplyRepayUsd / debtPrice);
2211
3089
  } else {
2212
- caseId = CaseId.LTV_EXTENDED_PROFITABLE_AVAILABLE;
3090
+ debtTokenDelta = -debtUsdFallback;
2213
3091
  }
3092
+ const debtAmtW3 = new Web3Number(debtTokenDelta.toFixed(USDC_TOKEN_DECIMALS), USDC_TOKEN_DECIMALS);
3093
+ multiplyDebtRepayUsd = Math.abs(debtTokenDelta) * debtPrice;
3094
+ routes.push({
3095
+ type: RouteType.VESU_MULTIPLY_DECREASE_LEVER,
3096
+ poolId: vesuAdapter.config.poolId,
3097
+ collateralToken: vesuAdapter.config.collateral,
3098
+ marginAmount: new Web3Number(marginBtc.toFixed(COLLATERAL_PRECISION), vesuAdapter.config.collateral.decimals),
3099
+ swappedCollateralAmount: new Web3Number(swappedBtc.toFixed(COLLATERAL_PRECISION), vesuAdapter.config.collateral.decimals),
3100
+ debtToken: vesuAdapter.config.debt,
3101
+ debtAmount: debtAmtW3,
3102
+ priority: routes.length,
3103
+ } as VesuMultiplyRoute);
3104
+ this._budget.applyVesuDelta(
3105
+ vesuAdapter.config.poolId,
3106
+ vesuAdapter.config.collateral,
3107
+ vesuAdapter.config.debt,
3108
+ new Web3Number((-xBtc).toFixed(COLLATERAL_PRECISION), vesuAdapter.config.collateral.decimals),
3109
+ debtAmtW3,
3110
+ );
3111
+ if (transferUsdFromVesu > CASE_THRESHOLD_USD) {
3112
+ this._budget.addToVA(transferUsdFromVesu);
3113
+ }
3114
+ } else if (d.dVesuPosition > btcEps) {
3115
+ const vesuDepositAmount = new Web3Number(
3116
+ (d.dVesuPosition * price * (1 - targetLtv)).toFixed(USDC_TOKEN_DECIMALS),
3117
+ USDC_TOKEN_DECIMALS,
3118
+ );
3119
+ if (vesuDepositAmount.toNumber() > CASE_THRESHOLD_USD) {
3120
+ routes.push({
3121
+ type: RouteType.AVNU_DEPOSIT_SWAP,
3122
+ priority: routes.length,
3123
+ fromToken: vesuAdapter.config.collateral.symbol,
3124
+ fromAmount: vesuDepositAmount,
3125
+ toToken: vesuAdapter.config.debt.symbol,
3126
+ });
3127
+ }
3128
+ const collateralDelta = new Web3Number(
3129
+ d.dVesuPosition.toFixed(COLLATERAL_PRECISION),
3130
+ vesuAdapter.config.collateral.decimals,
3131
+ );
3132
+ const availableBorrowCapacity = this._budget.vesuBorrowCapacity;
3133
+ const externalDepositAmount = vesuDepositAmount.minus(
3134
+ new Web3Number(Math.min(availableBorrowCapacity, vesuDepositAmount.toNumber()).toFixed(USDC_TOKEN_DECIMALS), USDC_TOKEN_DECIMALS),
3135
+ );
3136
+ const collPx = pool.collateralPrice || 1;
3137
+ const swappedAmount = new Web3Number(
3138
+ ((externalDepositAmount.toNumber() * (pool.debtPrice ?? 1) / collPx)).toFixed(6),
3139
+ vesuAdapter.config.collateral.decimals,
3140
+ );
3141
+ const debtDeltaTokens = new Web3Number(
3142
+ (d.dVesuDebt).toFixed(USDC_TOKEN_DECIMALS),
3143
+ USDC_TOKEN_DECIMALS,
3144
+ );
3145
+ routes.push({
3146
+ type: RouteType.VESU_MULTIPLY_INCREASE_LEVER,
3147
+ priority: routes.length,
3148
+ collateralToken: vesuAdapter.config.collateral,
3149
+ debtToken: vesuAdapter.config.debt,
3150
+ marginAmount: swappedAmount,
3151
+ swappedCollateralAmount: collateralDelta.minus(swappedAmount),
3152
+ debtAmount: debtDeltaTokens.plus(new Web3Number(Math.min(availableBorrowCapacity, vesuDepositAmount.toNumber()).toFixed(USDC_TOKEN_DECIMALS), USDC_TOKEN_DECIMALS)),
3153
+ poolId: vesuAdapter.config.poolId,
3154
+ } as VesuMultiplyRoute);
3155
+ this._budget.applyVesuDelta(
3156
+ vesuAdapter.config.poolId,
3157
+ vesuAdapter.config.collateral,
3158
+ vesuAdapter.config.debt,
3159
+ collateralDelta,
3160
+ debtDeltaTokens,
3161
+ );
3162
+ }
2214
3163
 
2215
- totalExtUsed = usableWithdrawAmount + upnlUsed;
3164
+ // ── 2) Extended lever second ──────────────────────────────────────
3165
+ if (d.dExtPosition < -btcEps) {
3166
+ const amt = new Web3Number(d.dExtPosition.toFixed(COLLATERAL_PRECISION), 8);
3167
+ routes.push({
3168
+ type: RouteType.EXTENDED_DECREASE_LEVER,
3169
+ amount: amt,
3170
+ instrument,
3171
+ priority: routes.length,
3172
+ } as ExtendedLeverRoute);
3173
+ this._budget.applyExtendedExposureDelta(instrument, amt, price);
3174
+ } else if (d.dExtPosition > btcEps) {
3175
+ const amt = new Web3Number(d.dExtPosition.toFixed(COLLATERAL_PRECISION), 8);
3176
+ routes.push({
3177
+ type: RouteType.EXTENDED_INCREASE_LEVER,
3178
+ amount: amt,
3179
+ instrument,
3180
+ priority: routes.length,
3181
+ } as ExtendedLeverRoute);
3182
+ this._budget.applyExtendedExposureDelta(instrument, amt, price);
2216
3183
  }
2217
3184
 
2218
- if (remaining > CASE_THRESHOLD_USD) {
2219
- throw new Error(`${this._tag}: Insufficient funds to cover margin needs`);
3185
+ // ── 3) Aggregated transfers (no WALLET_TO_EXTENDED; Ext only via VA)
3186
+ const negUpnl = Math.min(0, d.dExtUpnl);
3187
+ const negExtAvl = Math.min(0, d.dExtAvlWithdraw);
3188
+ let hadExtendedOut = false;
3189
+
3190
+ if (negUpnl < -CASE_THRESHOLD_USD) {
3191
+ this._getUpnlRoute(Math.abs(negUpnl), routes);
3192
+ hadExtendedOut = true;
2220
3193
  }
2221
3194
 
2222
- // ── Deferred: Extended→Wallet + Wallet→VA ───────────────────────────
2223
- if (totalExtUsed > CASE_THRESHOLD_USD) {
2224
- this._getExtendedToWalletRoute(totalExtUsed, routes);
2225
- this._getWALLETToVARoute(totalExtUsed, routes);
3195
+ const extToWalletUsd = (negExtAvl < -CASE_THRESHOLD_USD ? Math.abs(negExtAvl) : 0)
3196
+ + (negUpnl < -CASE_THRESHOLD_USD ? Math.abs(negUpnl) : 0);
3197
+ if (extToWalletUsd > CASE_THRESHOLD_USD) {
3198
+ this._getExtendedToWalletRoute(extToWalletUsd, routes);
3199
+ hadExtendedOut = true;
2226
3200
  }
2227
3201
 
2228
- // add routes linked to VESU repay
2229
- this._buildVesuRepayRoutes(needed - remaining, routes);
3202
+ const walletPull = Math.abs(Math.min(0, d.dWalletUsd));
3203
+ const walletToVaUsd = walletPull + extToWalletUsd;
3204
+ if (walletToVaUsd > CASE_THRESHOLD_USD) {
3205
+ this._getWALLETToVARoute(walletToVaUsd, routes);
3206
+ }
2230
3207
 
2231
- routes.forEach((r, i) => { r.priority = i; });
3208
+ if (d.dVesuBorrowCapacity < -CASE_THRESHOLD_USD) {
3209
+ this._buildVesuBorrowRoutes(Math.abs(d.dVesuBorrowCapacity), routes);
3210
+ }
2232
3211
 
2233
- return [{
2234
- case: CASE_DEFINITIONS[caseId],
2235
- additionalArgs: { amount: safeUsdcWeb3Number(needed) },
2236
- routes,
2237
- }];
3212
+ const totalDebtRepayUsd = d.dVesuDebt < 0 ? -d.dVesuDebt * debtPrice : 0;
3213
+ const standaloneRepayUsd = Math.max(0, totalDebtRepayUsd - multiplyDebtRepayUsd);
3214
+ if (standaloneRepayUsd > CASE_THRESHOLD_USD) {
3215
+ this._buildVesuRepayRoutes(standaloneRepayUsd, routes);
3216
+ }
3217
+
3218
+ const posExtEq = Math.max(0, d.dExtAvlWithdraw);
3219
+ const vaToExtUsd = posExtEq > CASE_THRESHOLD_USD ? posExtEq : 0;
3220
+ if (vaToExtUsd > CASE_THRESHOLD_USD) {
3221
+ this._getVAToEXTENDEDRoute(vaToExtUsd, routes, hadExtendedOut);
3222
+ }
3223
+
3224
+ return routes;
2238
3225
  }
2239
3226
 
2240
3227
  // ── LTV Vesu route builders ───────────────────────────────────────────
@@ -2335,66 +3322,7 @@ export class ExtendedSVKVesuStateManager {
2335
3322
  // return routes;
2336
3323
  // }
2337
3324
 
2338
- /** 2b. LTV Rebalance Extended side */
2339
- /**
2340
- * 2b. LTV Rebalance — Extended side
2341
- *
2342
- * Triggered when Extended equity is below the required margin for current positions.
2343
- * Sources funds to Extended via VA/Wallet or Vesu borrow.
2344
- *
2345
- * Priority: 1) VA/Wallet → Extended 2) Vesu borrow → VA → Extended
2346
- */
2347
- private _classifyLtvExtended(): SolveCaseEntry[] {
2348
- const totalExtPosUsd = this._totalExtendedExposureUsd().toNumber();
2349
- const extEquity = this._budget.extendedBalance?.equity?.toNumber() ?? 0;
2350
- const lev = calculateExtendedLevergae();
2351
- const marginNeeded = lev > 0 ? totalExtPosUsd / lev - extEquity : 0;
2352
- if (marginNeeded <= CASE_THRESHOLD_USD) return [];
2353
-
2354
- let caseId: CaseId = CaseId.LTV_EXTENDED_HIGH_USE_VA_OR_WALLET;
2355
- let remaining = marginNeeded;
2356
- const routes: ExecutionRoute[] = [];
2357
-
2358
- // ── Step 1: VA + Wallet → Extended ──────────────────────────────────
2359
- if (this._budget.vaWalletUsd > CASE_THRESHOLD_USD && remaining > CASE_THRESHOLD_USD) {
2360
- const use = Math.min(this._budget.vaWalletUsd, remaining);
2361
-
2362
- // VA first, then wallet
2363
- if (this._budget.vaUsd > CASE_THRESHOLD_USD) {
2364
- const { remaining: vaRem } = this._getVAToEXTENDEDRoute(remaining, routes, false);
2365
- remaining = vaRem;
2366
- }
2367
- if (remaining > CASE_THRESHOLD_USD) {
2368
- const { remaining: walletRem } = this._getWalletToEXTENDEDRoute(remaining, routes, false);
2369
- remaining = walletRem;
2370
- }
2371
- }
2372
-
2373
- // ── Step 2: Vesu borrow → VA → Extended ─────────────────────────────
2374
- if (remaining > CASE_THRESHOLD_USD && this._budget.vesuBorrowCapacity > CASE_THRESHOLD_USD) {
2375
- const { remaining: borrowRem } = this._buildVesuBorrowRoutes(Math.min(remaining, this._budget.vesuBorrowCapacity), routes);
2376
- const borrowed = remaining - borrowRem;
2377
- if (remaining != borrowRem) {
2378
- const { remaining: vaRem } = this._getVAToEXTENDEDRoute(borrowed, routes, false);
2379
- }
2380
- remaining = borrowRem;
2381
- routes.forEach((r, i) => { r.priority = i; });
2382
- remaining -= borrowed;
2383
- caseId = CaseId.LTV_VESU_LOW_TO_EXTENDED;
2384
- }
2385
-
2386
- if (remaining > CASE_THRESHOLD_USD) {
2387
- throw new Error(`${this._tag}: Insufficient funds to cover margin needs`);
2388
- }
2389
-
2390
- routes.forEach((r, i) => { r.priority = i; });
2391
-
2392
- return [{
2393
- case: CASE_DEFINITIONS[caseId],
2394
- additionalArgs: { amount: safeUsdcWeb3Number(marginNeeded) },
2395
- routes,
2396
- }];
2397
- }
3325
+ // _classifyLtvExtended has been merged into the unified _classifyLTV above.
2398
3326
 
2399
3327
  // ── LTV Extended route builders ───────────────────────────────────────
2400
3328
 
@@ -2478,6 +3406,96 @@ export class ExtendedSVKVesuStateManager {
2478
3406
 
2479
3407
  // ! todo implement max lever amount per execution cycle
2480
3408
 
3409
+ private _rebalanceFunds({
3410
+ extAvlWithdraw,
3411
+ extUpnl,
3412
+ vaUsd,
3413
+ walletUsd,
3414
+ vesuBorrowCapacity,
3415
+ vesuLeverage,
3416
+ extendedLeverage,
3417
+ }: Inputs): Deltas {
3418
+ const total = extAvlWithdraw + extUpnl + vaUsd + walletUsd + vesuBorrowCapacity;
3419
+
3420
+ // Target eco split
3421
+ const extendedTarget = (total) / (1 + extendedLeverage / vesuLeverage);
3422
+ const extendedInitial = extAvlWithdraw + extUpnl;
3423
+
3424
+ let delta = extendedTarget - extendedInitial;
3425
+
3426
+ // Initialize deltas (0 = no change)
3427
+ let dExtAvlWithdraw = 0,
3428
+ dExtUpnl = 0,
3429
+ dVaUsd = 0,
3430
+ dWalletUsd = 0,
3431
+ dVesuBorrowCapacity = 0;
3432
+
3433
+ // --- Case 1: eco1 needs MORE funds (pull from eco2)
3434
+ if (delta > 0) {
3435
+ let need = delta;
3436
+
3437
+ const takeWalletUsd = Math.min(walletUsd, need);
3438
+ dWalletUsd -= takeWalletUsd;
3439
+ need -= takeWalletUsd;
3440
+
3441
+ const takeVaUsd = Math.min(vaUsd, need);
3442
+ dVaUsd -= takeVaUsd;
3443
+ need -= takeVaUsd;
3444
+
3445
+ const takeVesuBorrowCapacity = Math.min(vesuBorrowCapacity, need);
3446
+ dVesuBorrowCapacity -= takeVesuBorrowCapacity;
3447
+ need -= takeVesuBorrowCapacity;
3448
+
3449
+ // Received into eco1 → distribute proportionally into E1/E2
3450
+ const received = delta - need;
3451
+ const eco1Sum = extAvlWithdraw + extUpnl;
3452
+
3453
+ if (eco1Sum >= 0) {
3454
+ // any received amount is always given to extended avl withdaw only. upnl wont change
3455
+ dExtAvlWithdraw += received;
3456
+ } else {
3457
+ // dont think it can be negative
3458
+ throw new Error(`${this._tag}: Unexpected case`);
3459
+ }
3460
+
3461
+ if (need > 0) {
3462
+ throw new Error(`${this._tag}: Insufficient funds to cover margin needs`);
3463
+ }
3464
+ }
3465
+
3466
+ // --- Case 2: eco2 needs MORE funds (push from eco1)
3467
+ else if (delta < 0) {
3468
+ let need = -delta;
3469
+
3470
+ const takeExtAvlWithdraw = Math.min(extAvlWithdraw, need);
3471
+ dExtAvlWithdraw -= takeExtAvlWithdraw;
3472
+ need -= takeExtAvlWithdraw;
3473
+
3474
+ const takeExtUpnl = Math.min(extUpnl, need);
3475
+ dExtUpnl -= takeExtUpnl;
3476
+ need -= takeExtUpnl;
3477
+
3478
+ const sent = -delta - need;
3479
+
3480
+ // Distribute into eco2 proportionally (optional design choice)
3481
+ const eco2Sum = vaUsd + walletUsd + vesuBorrowCapacity;
3482
+
3483
+ if (eco2Sum >= 0) {
3484
+ // all amount is sent to wallet only
3485
+ dWalletUsd += sent;
3486
+ } else {
3487
+ // dont think it can be negative
3488
+ throw new Error(`${this._tag}: Unexpected case`);
3489
+ }
3490
+
3491
+ if (need > 0) {
3492
+ throw new Error(`${this._tag}: Insufficient funds to cover margin needs`);
3493
+ }
3494
+ }
3495
+
3496
+ return { dExtAvlWithdraw, dExtUpnl, dVaUsd, dWalletUsd, dVesuBorrowCapacity, isExtendedToVesu: delta < 0 };
3497
+ }
3498
+
2481
3499
  /**
2482
3500
  * 3. New Deposits / Excess Funds
2483
3501
  *
@@ -2492,17 +3510,29 @@ export class ExtendedSVKVesuStateManager {
2492
3510
  * Computes allocation split between Vesu and Extended, then sources
2493
3511
  * funds and creates lever-increase routes.
2494
3512
  *
2495
- * Fund flow (principle #3accumulate transfers, defer wait):
2496
- * Phase A: fund Extended (wallet→ext, VA→ext, vesu-borrow→VA→ext)
2497
- * Phase B: fund Vesu VA shortfall (wallet→VA, ext→wallet + wallet→VA)
2498
- * Phase C: RETURN_TO_WAIT (if any transfer to Extended occurred)
2499
- * Phase D: lever routes (VESU_MULTIPLY, EXTENDED_INCREASE) near each other (#4)
3513
+ * Fund flow (single passavoid VA→Extended then Extended→wallet round-trips):
3514
+ * 1) Treat Vesu borrow headroom that the multiply route will consume as covering
3515
+ * part of the Vesu USDC need (no standalone VESU_BORROW for that slice). Cap
3516
+ * standalone VESU_BORROW→VA→Extended by remaining headroom.
3517
+ * 2) Cover Extended deposit delta: REALISE_PNL first, then withdrawal→avail-trade,
3518
+ * then wallet→Extended, then VA→Extended only up to VA surplus above the USDC
3519
+ * that must remain for Vesu (after step 1), then borrow→VA→Extended.
3520
+ * 3) Cover Vesu VA shortfall: wallet→VA, Extended withdrawal→wallet→VA, REALISE_PNL,
3521
+ * then combined Extended→wallet→VA for the remainder.
3522
+ * 4) RETURN_TO_WAIT when needed; then AVNU + VESU_MULTIPLY + EXTENDED_INCREASE.
2500
3523
  */
2501
- private _classifyDeposits(withdrawAmount: Web3Number): SolveCaseEntry[] {
3524
+ /**
3525
+ * @param skipAvnuDepositSwap Omit AVNU before Vesu multiply when LTV cases already ran this cycle
3526
+ * (matrix tests expect deposit routes without that step).
3527
+ */
3528
+ private _classifyDeposits(
3529
+ withdrawAmount: Web3Number,
3530
+ skipAvnuDepositSwap = false,
3531
+ ): SolveCaseEntry[] {
2502
3532
  if (withdrawAmount.toNumber() > CASE_THRESHOLD_USD) return [];
2503
3533
 
2504
3534
  const distributableAmount = this._computeDistributableAmount(
2505
- this._budget.vesuPerPoolDebtDeltasToBorrow, withdrawAmount,
3535
+ this._budget.vesuDebtDeltas, withdrawAmount,
2506
3536
  );
2507
3537
  if (distributableAmount.toNumber() <= CASE_THRESHOLD_USD) return [];
2508
3538
 
@@ -2510,96 +3540,75 @@ export class ExtendedSVKVesuStateManager {
2510
3540
  this._computeAllocationSplit(distributableAmount);
2511
3541
 
2512
3542
  const vesuDeltas = this._computePerPoolCollateralDeltas(
2513
- vesuAllocationUsd, this._budget.vesuPerPoolDebtDeltasToBorrow,
3543
+ vesuAllocationUsd
2514
3544
  );
2515
3545
 
2516
3546
  const extendedPositionDeltas = this._computeExtendedPositionDeltas(vesuDeltas);
2517
- const extendedDepositDelta = this._computeExtendedDepositDelta(extendedAllocationUsd);
2518
3547
  const vesuDepositAmount = this._computeVesuDepositAmount(vesuDeltas);
3548
+ const vesuLeverage = calculateVesuLeverage();
3549
+ const extendedLeverage = calculateExtendedLevergae();
2519
3550
 
3551
+ const { dExtAvlWithdraw, dExtUpnl, dVaUsd, dWalletUsd, dVesuBorrowCapacity, isExtendedToVesu } = this._rebalanceFunds({
3552
+ extAvlWithdraw: this._budget.extAvailWithdraw,
3553
+ extUpnl: this._budget.extAvailUpnl,
3554
+ vaUsd: this._budget.vaUsd,
3555
+ walletUsd: this._budget.walletUsd,
3556
+ vesuBorrowCapacity: this._budget.vesuBorrowCapacity,
3557
+ vesuLeverage,
3558
+ extendedLeverage,
3559
+ });
2520
3560
  const routes: ExecutionRoute[] = [];
2521
- let needsWait = false;
2522
3561
 
2523
- // ── Phase A: Fund Extended ──────────────────────────────────────────
2524
- if (extendedDepositDelta.toNumber() > CASE_THRESHOLD_USD) {
2525
- let rem = extendedDepositDelta.toNumber();
2526
-
2527
- // Wallet → Extended
2528
- if (rem > CASE_THRESHOLD_USD) {
2529
- const { remaining } = this._getWalletToEXTENDEDRoute(rem, routes, false);
2530
- if (remaining < rem) needsWait = true;
2531
- rem = remaining;
3562
+ if (isExtendedToVesu) {
3563
+ // negative means to spend
3564
+ if (dExtUpnl < 0) {
3565
+ this._getUpnlRoute(Math.abs(dExtUpnl), routes);
2532
3566
  }
2533
3567
 
2534
- // VA Extended
2535
- if (rem > CASE_THRESHOLD_USD) {
2536
- const { remaining } = this._getVAToEXTENDEDRoute(rem, routes, false);
2537
- if (remaining < rem) needsWait = true;
2538
- rem = remaining;
3568
+ if (dExtUpnl < 0 || dExtAvlWithdraw < 0) {
3569
+ const netAmount = (dExtAvlWithdraw < 0 ? Math.abs(dExtAvlWithdraw) : 0) + (dExtUpnl < 0 ? Math.abs(dExtUpnl) : 0);
3570
+ const walletUsd = this._budget.walletUsd; // its important to put it here, to ensure below route's impact on walletUsd doesnt double count later
3571
+ this._getExtendedToWalletRoute(netAmount, routes);
3572
+ this._getWALLETToVARoute(netAmount + walletUsd, routes); // add wallet usd to send entire amount to va
2539
3573
  }
2540
-
2541
- // Vesu borrow → VA → Extended
2542
- if (rem > CASE_THRESHOLD_USD && this._budget.vesuBorrowCapacity > CASE_THRESHOLD_USD) {
2543
- const { remaining: borrowRem } = this._buildVesuBorrowRoutes(rem, routes);
2544
- const borrowed = rem - borrowRem;
2545
- if (borrowRem != rem) {
2546
- this._getVAToEXTENDEDRoute(borrowed, routes, false);
2547
- needsWait = true;
2548
- rem = borrowRem;
2549
- }
3574
+ } else {
3575
+ let netDVaUsd = dVaUsd;
3576
+ if (dWalletUsd < 0) {
3577
+ // always send entire amount to va (no use it being in wallet)
3578
+ this._getWalletToVARoute(this._budget.walletUsd, routes);
3579
+ netDVaUsd += dWalletUsd;
2550
3580
  }
2551
- }
2552
-
2553
- // ── Phase B: Fund Vesu VA shortfall ─────────────────────────────────
2554
- if (vesuDepositAmount.toNumber() > CASE_THRESHOLD_USD) {
2555
- const vaShortfall = vesuDepositAmount.toNumber() - this._budget.vaUsd;
2556
- if (vaShortfall > CASE_THRESHOLD_USD) {
2557
- let rem = vaShortfall;
2558
-
2559
- // Wallet → VA
2560
- if (rem > CASE_THRESHOLD_USD && this._budget.walletUsd > CASE_THRESHOLD_USD) {
2561
- const { remaining } = this._getWalletToVARoute(Math.min(this._budget.walletUsd, rem), routes);
2562
- rem = remaining;
2563
- }
2564
-
2565
- // check if withdrawal is enough to cover the shortfall
2566
- // if not, we visit upnl first
2567
- const isWithdrawalEnough = rem <= this._budget.extAvailWithdraw;
2568
- if (!isWithdrawalEnough && rem > CASE_THRESHOLD_USD) {
2569
- const { remaining: upnlRem } = this._getUpnlRoute(rem, routes);
2570
- rem = upnlRem;
2571
- }
2572
3581
 
2573
- // Extended Wallet → VA (needs wait)
2574
- if (rem > CASE_THRESHOLD_USD && this._budget.extAvailWithdraw > CASE_THRESHOLD_USD) {
2575
- const extUse = Math.min(rem, this._budget.extAvailWithdraw);
2576
- this._getExtendedToWalletRoute(extUse, routes);
2577
- this._getWALLETToVARoute(extUse, routes);
2578
- rem -= extUse;
2579
- needsWait = false; // _getExtendedToWalletRoute already added RETURN_TO_WAIT
2580
- }
3582
+ if (dVesuBorrowCapacity < 0) {
3583
+ this._buildVesuBorrowRoutes(Math.abs(dVesuBorrowCapacity), routes);
3584
+ netDVaUsd += dVesuBorrowCapacity;
2581
3585
  }
2582
- }
2583
3586
 
2584
- // ── Phase C: Wait for transfers to settle ───────────────────────────
2585
- if (needsWait) {
2586
- routes.push({ type: RouteType.RETURN_TO_WAIT, priority: routes.length });
3587
+ if (netDVaUsd < 0) {
3588
+ this._getVAToEXTENDEDRoute(Math.abs(netDVaUsd), routes, true); // true means, add wait route always
3589
+ }
2587
3590
  }
2588
3591
 
2589
- // ── Phase D: Vesu lever increase ────────────────────────────────────
3592
+ // ── Vesu lever increase ─────────────────────────────────────────────
2590
3593
  for (const vesuDelta of vesuDeltas) {
2591
- if (vesuDepositAmount.toNumber() > CASE_THRESHOLD_USD) {
2592
- // add avnu deposit swap route
2593
- // routes.push({
2594
- // type: RouteType.AVNU_DEPOSIT_SWAP,
2595
- // priority: routes.length,
2596
- // fromToken: vesuDelta.collateralToken.symbol,
2597
- // fromAmount: vesuDepositAmount,
2598
- // toToken: vesuDelta.debtToken.symbol,
2599
- // });
3594
+ if (
3595
+ !skipAvnuDepositSwap &&
3596
+ vesuDepositAmount.toNumber() > CASE_THRESHOLD_USD
3597
+ ) {
3598
+ routes.push({
3599
+ type: RouteType.AVNU_DEPOSIT_SWAP,
3600
+ priority: routes.length,
3601
+ fromToken: vesuDelta.collateralToken.symbol,
3602
+ fromAmount: vesuDepositAmount,
3603
+ toToken: vesuDelta.debtToken.symbol,
3604
+ });
2600
3605
  }
2601
3606
  if (vesuDelta.collateralDelta.toNumber() > 0) {
2602
- const swappedAmount = new Web3Number((vesuDepositAmount.toNumber() * vesuDelta.debtPrice / (vesuDelta.collateralPrice ?? 0)).toFixed(6), vesuDelta.collateralToken.decimals);
3607
+ // removes borrowing capacity after excluding delta capacity that is being briddged out
3608
+ // this is bcz, since bnorrow capacity exists, no need for external margin amount
3609
+ const availableBorrowCapacity = this._budget.vesuBorrowCapacity;
3610
+ const externalDepositAmount = vesuDepositAmount.minus(availableBorrowCapacity);
3611
+ const swappedAmount = new Web3Number((externalDepositAmount.toNumber() * vesuDelta.debtPrice / (vesuDelta.collateralPrice ?? 0)).toFixed(6), vesuDelta.collateralToken.decimals);
2603
3612
  routes.push({
2604
3613
  type: RouteType.VESU_MULTIPLY_INCREASE_LEVER,
2605
3614
  priority: routes.length,
@@ -2607,13 +3616,13 @@ export class ExtendedSVKVesuStateManager {
2607
3616
  debtToken: vesuDelta.debtToken,
2608
3617
  marginAmount: swappedAmount, // should be the swapped amount as per vesu multiply adapter
2609
3618
  swappedCollateralAmount: vesuDelta.collateralDelta.minus(swappedAmount),
2610
- debtAmount: vesuDelta.debtDelta,
3619
+ debtAmount: vesuDelta.debtDelta.plus(availableBorrowCapacity),
2611
3620
  poolId: vesuDelta.poolId,
2612
3621
  } as VesuMultiplyRoute);
2613
3622
  }
2614
3623
  }
2615
3624
 
2616
- // ── Phase D: Extended lever increase ────────────────────────────────
3625
+ // ── Extended lever increase ────────────────────────────────────────
2617
3626
  for (const epDelta of extendedPositionDeltas) {
2618
3627
  if (epDelta.delta.toNumber() > 0) {
2619
3628
  routes.push({
@@ -2744,8 +3753,14 @@ export class ExtendedSVKVesuStateManager {
2744
3753
  */
2745
3754
  private _buildImbalanceExtExcessShortNoFundsRoutes(exposureDiffBtc: number): ExecutionRoute[] {
2746
3755
  const instrument = this._config.extendedAdapter.config.extendedMarketName ?? 'BTC-USD';
2747
- const decDelta = new Web3Number(exposureDiffBtc.toFixed(COLLATERAL_PRECISION), USDC_TOKEN_DECIMALS);
2748
- this._budget.applyExtendedExposureDelta(instrument, new Web3Number(decDelta.negated().toFixed(COLLATERAL_PRECISION), USDC_TOKEN_DECIMALS));
3756
+ // hardcoding 8 decimals is ok here
3757
+ const decDelta = new Web3Number(Web3Number.fromNumber(exposureDiffBtc, 8).toFixedRoundDown(COLLATERAL_PRECISION), USDC_TOKEN_DECIMALS);
3758
+ const collPx = this._budget.vesuPools[0]?.collateralPrice ?? 1;
3759
+ this._budget.applyExtendedExposureDelta(
3760
+ instrument,
3761
+ new Web3Number(Web3Number.fromNumber(decDelta.negated().toNumber(), 8).toFixedRoundDown(COLLATERAL_PRECISION), USDC_TOKEN_DECIMALS),
3762
+ collPx,
3763
+ );
2749
3764
  return [{
2750
3765
  type: RouteType.EXTENDED_DECREASE_LEVER,
2751
3766
  amount: decDelta,
@@ -2820,24 +3835,24 @@ export class ExtendedSVKVesuStateManager {
2820
3835
  private _classifyCases(withdrawAmount: Web3Number): SolveCaseEntry[] {
2821
3836
  this._budget.initBudget();
2822
3837
 
2823
- // withdrawal is simply about available funds and unwinding positions.
3838
+ // withdrawal uses full raw balances (no safety buffer)
2824
3839
  const withdrawalCases = this._classifyWithdrawal(withdrawAmount);
2825
3840
 
2826
- // 2. LTV Rebalance Vesu high LTV (fund movement to VA)
2827
- const ltvVesuCases = this._classifyLtvVesu();
2828
-
2829
- // 3. LTV Rebalance — Extended low margin
2830
- const ltvExtendedCases = this._classifyLtvExtended();
3841
+ // apply safety buffer to remaining balances for subsequent classifiers
3842
+ this._budget.applyBuffer(this._config.limitBalanceBufferFactor);
2831
3843
 
2832
- // 4. New Depositsallocate remaining budget to lever operations
2833
- const depositCases = this._classifyDeposits(withdrawAmount);
3844
+ // 2. LTV Rebalanceunified Vesu high LTV + Extended low margin
3845
+ const ltvCases = this._classifyLTV();
2834
3846
 
2835
- // ...this._classifyImbalance(vesuDeltas),
3847
+ // 3. New Deposits — allocate remaining budget to lever operations
3848
+ const depositCases = this._classifyDeposits(
3849
+ withdrawAmount,
3850
+ ltvCases.length > 0,
3851
+ );
2836
3852
 
2837
3853
  return [
2838
3854
  ...withdrawalCases,
2839
- ...ltvVesuCases,
2840
- ...ltvExtendedCases,
3855
+ ...ltvCases,
2841
3856
  ...depositCases,
2842
3857
  ];
2843
3858
  }
@@ -2849,7 +3864,7 @@ export class ExtendedSVKVesuStateManager {
2849
3864
  // ═══════════════════════════════════════════════════════════════════════════
2850
3865
 
2851
3866
  private _totalVesuCollateral(): Web3Number {
2852
- return this._budget.vesuPoolStates.reduce(
3867
+ return this._budget.vesuPools.reduce(
2853
3868
  (acc, pool) =>
2854
3869
  acc.plus(
2855
3870
  pool.collateralAmount,
@@ -2859,7 +3874,7 @@ export class ExtendedSVKVesuStateManager {
2859
3874
  }
2860
3875
 
2861
3876
  private _totalVesuCollateralUsd(): Web3Number {
2862
- return this._budget.vesuPoolStates.reduce(
3877
+ return this._budget.vesuPools.reduce(
2863
3878
  (acc, pool) =>
2864
3879
  acc.plus(
2865
3880
  pool.collateralAmount.multipliedBy(pool.collateralPrice),
@@ -2869,14 +3884,14 @@ export class ExtendedSVKVesuStateManager {
2869
3884
  }
2870
3885
 
2871
3886
  private _totalExtendedExposure(): Web3Number {
2872
- return this._budget.extendedPositions.reduce(
3887
+ return this._budget.extendedPositionsView.reduce(
2873
3888
  (acc, position) => acc.plus(position.size),
2874
3889
  new Web3Number(0, USDC_TOKEN_DECIMALS),
2875
3890
  );
2876
3891
  }
2877
3892
 
2878
3893
  private _totalExtendedExposureUsd(): Web3Number {
2879
- return this._budget.extendedPositions.reduce(
3894
+ return this._budget.extendedPositionsView.reduce(
2880
3895
  (acc, position) => acc.plus(position.valueUsd),
2881
3896
  new Web3Number(0, USDC_TOKEN_DECIMALS),
2882
3897
  );