@epicentral/sos-sdk 0.9.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.env.example +1 -0
  2. package/AGENTS.md +7 -0
  3. package/LICENSE +21 -0
  4. package/README.md +568 -0
  5. package/accounts/fetchers.ts +196 -0
  6. package/accounts/list.ts +184 -0
  7. package/accounts/pdas.ts +325 -0
  8. package/accounts/resolve-option.ts +104 -0
  9. package/client/lookup-table.ts +114 -0
  10. package/client/program.ts +13 -0
  11. package/client/types.ts +9 -0
  12. package/generated/accounts/collateralPool.ts +217 -0
  13. package/generated/accounts/config.ts +156 -0
  14. package/generated/accounts/escrowState.ts +183 -0
  15. package/generated/accounts/index.ts +20 -0
  16. package/generated/accounts/lenderPosition.ts +211 -0
  17. package/generated/accounts/makerCollateralShare.ts +229 -0
  18. package/generated/accounts/marketDataAccount.ts +176 -0
  19. package/generated/accounts/optionAccount.ts +247 -0
  20. package/generated/accounts/optionPool.ts +285 -0
  21. package/generated/accounts/poolLoan.ts +232 -0
  22. package/generated/accounts/positionAccount.ts +201 -0
  23. package/generated/accounts/vault.ts +366 -0
  24. package/generated/accounts/writerPosition.ts +327 -0
  25. package/generated/errors/index.ts +9 -0
  26. package/generated/errors/optionProgram.ts +476 -0
  27. package/generated/index.ts +13 -0
  28. package/generated/instructions/acceptAdmin.ts +230 -0
  29. package/generated/instructions/autoExerciseAllExpired.ts +685 -0
  30. package/generated/instructions/autoExerciseExpired.ts +754 -0
  31. package/generated/instructions/borrowFromPool.ts +619 -0
  32. package/generated/instructions/buyFromPool.ts +761 -0
  33. package/generated/instructions/closeLongToPool.ts +762 -0
  34. package/generated/instructions/closeOption.ts +235 -0
  35. package/generated/instructions/createEscrowV2.ts +518 -0
  36. package/generated/instructions/depositCollateral.ts +624 -0
  37. package/generated/instructions/depositToPosition.ts +429 -0
  38. package/generated/instructions/index.ts +47 -0
  39. package/generated/instructions/initCollateralPool.ts +513 -0
  40. package/generated/instructions/initConfig.ts +279 -0
  41. package/generated/instructions/initOptionPool.ts +587 -0
  42. package/generated/instructions/initializeMarketData.ts +359 -0
  43. package/generated/instructions/liquidateWriterPosition.ts +750 -0
  44. package/generated/instructions/liquidateWriterPositionRescue.ts +623 -0
  45. package/generated/instructions/omlpCreateVault.ts +553 -0
  46. package/generated/instructions/omlpUpdateFeeWallet.ts +473 -0
  47. package/generated/instructions/omlpUpdateInterestModel.ts +322 -0
  48. package/generated/instructions/omlpUpdateLiquidationThreshold.ts +304 -0
  49. package/generated/instructions/omlpUpdateMaintenanceBuffer.ts +304 -0
  50. package/generated/instructions/omlpUpdateMaxBorrowCap.ts +304 -0
  51. package/generated/instructions/omlpUpdateMaxLeverage.ts +304 -0
  52. package/generated/instructions/omlpUpdateProtocolFee.ts +304 -0
  53. package/generated/instructions/omlpUpdateSupplyLimit.ts +304 -0
  54. package/generated/instructions/optionExercise.ts +617 -0
  55. package/generated/instructions/optionMint.ts +1373 -0
  56. package/generated/instructions/optionValidate.ts +302 -0
  57. package/generated/instructions/repayPoolLoan.ts +558 -0
  58. package/generated/instructions/repayPoolLoanFromCollateral.ts +514 -0
  59. package/generated/instructions/repayPoolLoanFromWallet.ts +542 -0
  60. package/generated/instructions/settleMakerCollateral.ts +509 -0
  61. package/generated/instructions/syncWriterPosition.ts +206 -0
  62. package/generated/instructions/transferAdmin.ts +245 -0
  63. package/generated/instructions/unwindWriterUnsold.ts +764 -0
  64. package/generated/instructions/updateImpliedVolatility.ts +226 -0
  65. package/generated/instructions/updateMarketData.ts +315 -0
  66. package/generated/instructions/withdrawFromPosition.ts +405 -0
  67. package/generated/instructions/writeToPool.ts +619 -0
  68. package/generated/programs/index.ts +9 -0
  69. package/generated/programs/optionProgram.ts +1144 -0
  70. package/generated/shared/index.ts +164 -0
  71. package/generated/types/impliedVolatilityUpdated.ts +73 -0
  72. package/generated/types/index.ts +28 -0
  73. package/generated/types/liquidationExecuted.ts +73 -0
  74. package/generated/types/liquidationRescueEvent.ts +82 -0
  75. package/generated/types/marketDataInitialized.ts +61 -0
  76. package/generated/types/marketDataUpdated.ts +69 -0
  77. package/generated/types/optionClosed.ts +56 -0
  78. package/generated/types/optionExercised.ts +62 -0
  79. package/generated/types/optionExpired.ts +49 -0
  80. package/generated/types/optionMinted.ts +78 -0
  81. package/generated/types/optionType.ts +38 -0
  82. package/generated/types/optionValidated.ts +82 -0
  83. package/generated/types/poolLoanCreated.ts +74 -0
  84. package/generated/types/poolLoanRepaid.ts +74 -0
  85. package/generated/types/positionDeposited.ts +73 -0
  86. package/generated/types/positionWithdrawn.ts +81 -0
  87. package/generated/types/protocolFeeUpdated.ts +69 -0
  88. package/generated/types/vaultCreated.ts +60 -0
  89. package/generated/types/vaultFeeWalletUpdated.ts +67 -0
  90. package/generated/types/vaultInterestModelUpdated.ts +77 -0
  91. package/generated/types/vaultLiquidationThresholdUpdated.ts +69 -0
  92. package/index.ts +68 -0
  93. package/long/builders.ts +690 -0
  94. package/long/exercise.ts +123 -0
  95. package/long/preflight.ts +214 -0
  96. package/long/quotes.ts +48 -0
  97. package/long/remaining-accounts.ts +111 -0
  98. package/omlp/builders.ts +94 -0
  99. package/omlp/service.ts +136 -0
  100. package/oracle/switchboard.ts +315 -0
  101. package/package.json +34 -0
  102. package/shared/amounts.ts +53 -0
  103. package/shared/balances.ts +57 -0
  104. package/shared/errors.ts +12 -0
  105. package/shared/remaining-accounts.ts +41 -0
  106. package/shared/trade-config.ts +27 -0
  107. package/shared/transactions.ts +121 -0
  108. package/short/builders.ts +874 -0
  109. package/short/close-option.ts +34 -0
  110. package/short/pool.ts +189 -0
  111. package/short/preflight.ts +619 -0
  112. package/tsconfig.json +13 -0
  113. package/wsol/instructions.ts +247 -0
@@ -0,0 +1,619 @@
1
+ import { toAddress } from "../client/program";
2
+ import type { AddressLike, KitRpc } from "../client/types";
3
+ import type { OptionType } from "../generated";
4
+ import { fetchCollateralPool, fetchVault, fetchWriterPosition } from "../accounts/fetchers";
5
+ import { fetchPoolLoansByMaker } from "../accounts/list";
6
+ import { deriveAssociatedTokenAddress, deriveVaultPda, deriveWriterPositionPda } from "../accounts/pdas";
7
+ import { resolveOptionAccounts } from "../accounts/resolve-option";
8
+ import { invariant } from "../shared/errors";
9
+
10
+ const TOKEN_ACCOUNT_AMOUNT_OFFSET = 64;
11
+ const BPS_DENOMINATOR = 10_000n;
12
+ /** Fixed-point scale for pool accumulators (matches on-chain `FP_SCALE = 10^12`). */
13
+ const FP_SCALE = 1_000_000_000_000n;
14
+ /** Mainnet-beta slots per year, matches `SLOTS_PER_YEAR` in option-program. */
15
+ const SLOTS_PER_YEAR = 78_840_000n;
16
+
17
+
18
+ function readTokenAccountAmount(data: Uint8Array): bigint {
19
+ if (data.length < TOKEN_ACCOUNT_AMOUNT_OFFSET + 8) return 0n;
20
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
21
+ return view.getBigUint64(TOKEN_ACCOUNT_AMOUNT_OFFSET, true);
22
+ }
23
+
24
+ async function fetchTokenAmount(rpc: KitRpc, tokenAccount: AddressLike): Promise<bigint> {
25
+ const response = await rpc.getAccountInfo(toAddress(tokenAccount), { encoding: "base64" }).send();
26
+ const info = response.value;
27
+ if (!info) return 0n;
28
+ const [base64Data] = info.data;
29
+ if (!base64Data) return 0n;
30
+ const decoded = atob(base64Data);
31
+ const bytes = new Uint8Array(decoded.length);
32
+ for (let i = 0; i < decoded.length; i++) bytes[i] = decoded.charCodeAt(i);
33
+ return readTokenAccountAmount(bytes);
34
+ }
35
+
36
+ function toBigInt(value: bigint | number): bigint {
37
+ return typeof value === "bigint" ? value : BigInt(value);
38
+ }
39
+
40
+ export function minBigInt(...values: bigint[]): bigint {
41
+ if (values.length === 0) return 0n;
42
+ return values.reduce((a, b) => (a < b ? a : b));
43
+ }
44
+
45
+ /**
46
+ * SPL balance can trail `proportionalCollateralShare` by a few base units
47
+ * (rounding / close-long debits vs `collateral_deposited`). Matches on-chain
48
+ * `collateral_vault_unwind_dust_epsilon` in option-program `pool.rs`.
49
+ */
50
+ export function collateralVaultUnwindDustEpsilon(proportionalCollateralShare: bigint): bigint {
51
+ const fromPpm = (proportionalCollateralShare * 50n) / 1_000_000n;
52
+ const from10Bps = (proportionalCollateralShare * 10n) / 10_000n;
53
+ let a = fromPpm < 128_000n ? 128_000n : fromPpm;
54
+ const combined = a > from10Bps ? a : from10Bps;
55
+ return combined > 5_000_000n ? 5_000_000n : combined;
56
+ }
57
+
58
+ /**
59
+ * When the writer position reports no OMLP borrow state, do not treat unrelated
60
+ * active pool loan accounts (same maker+vault) as unwind repayment debt.
61
+ * Matches on-chain: repayment only runs when the writer still tracks active loans.
62
+ */
63
+ export function writerPositionHasOngoingPoolLoanDebt(writerPosition: {
64
+ borrowedPrincipal: bigint | number;
65
+ activeLoanCount: number;
66
+ }): boolean {
67
+ return toBigInt(writerPosition.borrowedPrincipal) !== 0n || writerPosition.activeLoanCount > 0;
68
+ }
69
+
70
+ /**
71
+ * Client-side mirror of `WriterPosition::claimable_theta` + `theta_balance()`.
72
+ * Computes the theta balance that will be available on-chain after the
73
+ * instruction calls `realize_theta(pool.acc_theta_per_oi_fp)`.
74
+ */
75
+ function computeThetaBalance(
76
+ writerPosition: {
77
+ soldQty: bigint | number;
78
+ thetaEarned: bigint | number;
79
+ thetaClaimed: bigint | number;
80
+ lastPoolAccThetaFp: bigint | number;
81
+ },
82
+ poolAccThetaFp: bigint | number
83
+ ): bigint {
84
+ const accTheta = toBigInt(poolAccThetaFp);
85
+ const lastFp = toBigInt(writerPosition.lastPoolAccThetaFp);
86
+ const soldQty = toBigInt(writerPosition.soldQty);
87
+ const deltaFp = accTheta > lastFp ? accTheta - lastFp : 0n;
88
+ const pending = deltaFp > 0n && soldQty > 0n ? (soldQty * deltaFp) / FP_SCALE : 0n;
89
+ const earned = toBigInt(writerPosition.thetaEarned) + pending;
90
+ const claimed = toBigInt(writerPosition.thetaClaimed);
91
+ return earned > claimed ? earned - claimed : 0n;
92
+ }
93
+
94
+ /** `(value * numerator) / denominator`, saturating at 0 when denominator is 0. */
95
+ function proportionalAmount(value: bigint, numerator: bigint, denominator: bigint): bigint {
96
+ if (denominator === 0n) return 0n;
97
+ return (value * numerator) / denominator;
98
+ }
99
+
100
+ export interface PreflightUnwindWriterUnsoldParams {
101
+ underlyingAsset: AddressLike;
102
+ optionType: OptionType;
103
+ strikePrice: number;
104
+ expirationDate: bigint | number;
105
+ writer: AddressLike;
106
+ unwindQty: bigint | number;
107
+ rpc: KitRpc;
108
+ programId?: AddressLike;
109
+ underlyingMint?: AddressLike;
110
+ /**
111
+ * @deprecated Wallet-based repayment is no longer supported under the
112
+ * theta-hedge model; unwind pays theta-first then proportional collateral,
113
+ * and reverts if the writer is insolvent (callers should route to the
114
+ * keeper rescue path instead).
115
+ */
116
+ writerRepaymentAccount?: AddressLike;
117
+ }
118
+
119
+ export interface UnwindLoanBreakdown {
120
+ loanAddress: string;
121
+ principal: bigint;
122
+ accruedInterest: bigint;
123
+ accruedProtocolFees: bigint;
124
+ newlyAccruedInterest: bigint;
125
+ newlyAccruedProtocolFees: bigint;
126
+ totalInterest: bigint;
127
+ totalProtocolFees: bigint;
128
+ totalOwed: bigint;
129
+ }
130
+
131
+ export interface UnwindPreflightSummary {
132
+ activeLoanCount: number;
133
+ totalPrincipal: bigint;
134
+ totalInterest: bigint;
135
+ totalProtocolFees: bigint;
136
+ totalOwed: bigint;
137
+ /** Proportional obligations for partial unwind (based on unwind ratio). */
138
+ proportionalPrincipal: bigint;
139
+ proportionalInterest: bigint;
140
+ proportionalProtocolFees: bigint;
141
+ proportionalTotalOwed: bigint;
142
+ /** Proportional collateral claim the writer is owed if all debt is paid. */
143
+ proportionalCollateralShare: bigint;
144
+ /** Collateral returned to the writer after debt (pre-leftover-theta). */
145
+ returnableCollateral: bigint;
146
+ /** Total funds on hand in the collateral vault (SPL balance). */
147
+ collateralVaultAvailable: bigint;
148
+ /**
149
+ * Realized + pending theta available to offset debt on unwind.
150
+ * Mirrors `writer_position.realize_theta` followed by `theta_balance()`.
151
+ */
152
+ thetaAvailable: bigint;
153
+ /**
154
+ * Portion of `thetaAvailable` used to repay the proportional debt.
155
+ * Zero when `premium_vault.mint != collateral_pool.collateral_mint`.
156
+ */
157
+ thetaToDebt: bigint;
158
+ /** Residual debt after theta, paid from `collateral_vault`. */
159
+ collateralToDebt: bigint;
160
+ /** True when theta can offset debt (mint match with collateral pool). */
161
+ premiumMintMatchesCollateralMint: boolean;
162
+ /**
163
+ * Shortfall against the writer's proportional collateral claim; non-zero
164
+ * means the writer is underwater and the keeper rescue path is required.
165
+ */
166
+ collateralClaimShortfall: bigint;
167
+ /**
168
+ * Shortfall between vault SPL balance and `proportionalCollateralShare` for
169
+ * this unwind slice, ignoring dust within `collateralVaultUnwindDustEpsilon`.
170
+ */
171
+ collateralVaultShortfall: bigint;
172
+ /** True when the writer cannot fully repay the requested slice. */
173
+ needsRescue: boolean;
174
+ }
175
+
176
+ export interface UnwindPreflightResult {
177
+ canUnwind: boolean;
178
+ canRepayRequestedSlice: boolean;
179
+ /** @deprecated Use {@link canRepayRequestedSlice}. */
180
+ canRepayFully: boolean;
181
+ reason?: string;
182
+ /** Requested unwind quantity (from client). */
183
+ requestedUnwindQty: bigint;
184
+ /**
185
+ * Quantity used for repayment math and `unwind_writer_unsold` — `min` of requested, on-chain
186
+ * unsold, pool escrow LONG balance, and writer SHORT balance (matches program checks).
187
+ */
188
+ effectiveUnwindQty: bigint;
189
+ writerPositionAddress: string;
190
+ /**
191
+ * @deprecated Under the theta-hedge model debt is repaid from theta +
192
+ * collateral vault; this field is retained for ABI compatibility and
193
+ * simply resolves to the writer's default underlying ATA.
194
+ */
195
+ writerRepaymentAccount: string;
196
+ collateralVaultAddress: string;
197
+ loans: Array<UnwindLoanBreakdown>;
198
+ summary: UnwindPreflightSummary;
199
+ }
200
+
201
+ function emptySummary(): UnwindPreflightSummary {
202
+ return {
203
+ activeLoanCount: 0,
204
+ totalPrincipal: 0n,
205
+ totalInterest: 0n,
206
+ totalProtocolFees: 0n,
207
+ totalOwed: 0n,
208
+ proportionalPrincipal: 0n,
209
+ proportionalInterest: 0n,
210
+ proportionalProtocolFees: 0n,
211
+ proportionalTotalOwed: 0n,
212
+ proportionalCollateralShare: 0n,
213
+ returnableCollateral: 0n,
214
+ collateralVaultAvailable: 0n,
215
+ thetaAvailable: 0n,
216
+ thetaToDebt: 0n,
217
+ collateralToDebt: 0n,
218
+ premiumMintMatchesCollateralMint: false,
219
+ collateralClaimShortfall: 0n,
220
+ collateralVaultShortfall: 0n,
221
+ needsRescue: false,
222
+ };
223
+ }
224
+
225
+ export async function preflightUnwindWriterUnsold(
226
+ params: PreflightUnwindWriterUnsoldParams
227
+ ): Promise<UnwindPreflightResult> {
228
+ const resolved = await resolveOptionAccounts({
229
+ underlyingAsset: params.underlyingAsset,
230
+ optionType: params.optionType,
231
+ strikePrice: params.strikePrice,
232
+ expirationDate: params.expirationDate,
233
+ programId: params.programId,
234
+ rpc: params.rpc,
235
+ });
236
+
237
+ invariant(
238
+ !!resolved.collateralVault && !!resolved.collateralPool && !!resolved.underlyingMint,
239
+ "Option/collateral pool state is required for unwind preflight."
240
+ );
241
+ invariant(
242
+ !!resolved.optionPoolData,
243
+ "Option pool state is required for unwind preflight (theta accumulator)."
244
+ );
245
+ invariant(
246
+ !!resolved.collateralPoolData,
247
+ "Collateral pool state is required for unwind preflight (collateral mint)."
248
+ );
249
+
250
+ const optionPoolData = resolved.optionPoolData;
251
+ const collateralPoolData = resolved.collateralPoolData;
252
+
253
+ const underlyingMint = params.underlyingMint ?? resolved.underlyingMint;
254
+ const [vaultPda] = await deriveVaultPda(underlyingMint, params.programId);
255
+ const vaultPdaAddress = toAddress(vaultPda);
256
+ const writerDefaultRepaymentAta = await deriveAssociatedTokenAddress(params.writer, underlyingMint);
257
+ // `writerRepaymentAccount` stays in the result for ABI compatibility with
258
+ // existing callers (e.g. wallet-fallback UX from pre-convergence), but it
259
+ // is not used on-chain anymore.
260
+ const writerRepaymentAddress = toAddress(
261
+ params.writerRepaymentAccount ?? writerDefaultRepaymentAta
262
+ );
263
+ const [writerPositionAddress] = await deriveWriterPositionPda(
264
+ resolved.optionPool,
265
+ params.writer,
266
+ params.programId
267
+ );
268
+
269
+ // #region agent log
270
+ const __debugIngest = (stage: string, data: Record<string, unknown>) => {
271
+ try {
272
+ fetch("http://127.0.0.1:7586/ingest/4a07cb33-954d-4b27-b1a3-08f1423b9d05", {
273
+ method: "POST",
274
+ headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "af65cf" },
275
+ body: JSON.stringify({
276
+ sessionId: "af65cf",
277
+ location: "sos-sdk/short/preflight.ts:preflightUnwindWriterUnsold",
278
+ message: stage,
279
+ data,
280
+ timestamp: Date.now(),
281
+ hypothesisId: "H2-i64-writerPosition-or-poolLoan-size",
282
+ }),
283
+ }).catch(() => {});
284
+ } catch {}
285
+ };
286
+ // #endregion
287
+ // #region agent log
288
+ __debugIngest("before_account_fetch", {
289
+ writerPositionAddress: String(writerPositionAddress),
290
+ collateralPool: String(resolved.collateralPool),
291
+ vaultPda: String(vaultPda),
292
+ writer: String(params.writer),
293
+ });
294
+ // #endregion
295
+
296
+ let writerPosition: Awaited<ReturnType<typeof fetchWriterPosition>> | null = null;
297
+ let collateralPool: Awaited<ReturnType<typeof fetchCollateralPool>> | null = null;
298
+ let vault: Awaited<ReturnType<typeof fetchVault>> | null = null;
299
+ let loans: Awaited<ReturnType<typeof fetchPoolLoansByMaker>> = [];
300
+ let currentSlot: bigint | number = 0n;
301
+ try {
302
+ writerPosition = await fetchWriterPosition(params.rpc, writerPositionAddress);
303
+ // #region agent log
304
+ __debugIngest("fetchWriterPosition_ok", { hasPosition: !!writerPosition });
305
+ // #endregion
306
+ } catch (e) {
307
+ // #region agent log
308
+ __debugIngest("fetchWriterPosition_throw", {
309
+ err: (e as Error)?.message ?? String(e),
310
+ stack: String((e as Error)?.stack ?? "").slice(0, 600),
311
+ });
312
+ // #endregion
313
+ throw e;
314
+ }
315
+ try {
316
+ collateralPool = await fetchCollateralPool(params.rpc, resolved.collateralPool);
317
+ } catch (e) {
318
+ // #region agent log
319
+ __debugIngest("fetchCollateralPool_throw", { err: (e as Error)?.message ?? String(e) });
320
+ // #endregion
321
+ throw e;
322
+ }
323
+ try {
324
+ vault = await fetchVault(params.rpc, vaultPda);
325
+ } catch (e) {
326
+ // #region agent log
327
+ __debugIngest("fetchVault_throw", { err: (e as Error)?.message ?? String(e) });
328
+ // #endregion
329
+ throw e;
330
+ }
331
+ try {
332
+ loans = await fetchPoolLoansByMaker(params.rpc, params.writer);
333
+ // #region agent log
334
+ __debugIngest("fetchPoolLoansByMaker_ok", { count: loans.length });
335
+ // #endregion
336
+ } catch (e) {
337
+ // #region agent log
338
+ __debugIngest("fetchPoolLoansByMaker_throw", {
339
+ err: (e as Error)?.message ?? String(e),
340
+ stack: String((e as Error)?.stack ?? "").slice(0, 600),
341
+ });
342
+ // #endregion
343
+ throw e;
344
+ }
345
+ currentSlot = await params.rpc.getSlot().send();
346
+
347
+ invariant(!!writerPosition, "Writer position is required for unwind preflight.");
348
+ invariant(!!collateralPool, "Collateral pool is required for unwind preflight.");
349
+ invariant(!!vault, "Vault state is required for unwind preflight.");
350
+ invariant(
351
+ !!resolved.escrowLongAccount && !!resolved.shortMint,
352
+ "Option pool escrow LONG and short mint are required for unwind preflight."
353
+ );
354
+
355
+ const writerShortAta = await deriveAssociatedTokenAddress(params.writer, resolved.shortMint);
356
+ const [escrowLongBal, writerShortBal] = await Promise.all([
357
+ fetchTokenAmount(params.rpc, resolved.escrowLongAccount),
358
+ fetchTokenAmount(params.rpc, writerShortAta),
359
+ ]);
360
+
361
+ const unwindQtyRequested = toBigInt(params.unwindQty);
362
+ const unsoldQty = toBigInt(writerPosition.unsoldQty);
363
+ const unwindQtyEffective = minBigInt(unwindQtyRequested, unsoldQty, escrowLongBal, writerShortBal);
364
+
365
+ const baseResult = (
366
+ canUnwind: boolean,
367
+ canRepayRequestedSlice: boolean,
368
+ reason: string | undefined,
369
+ effectiveQty: bigint
370
+ ): UnwindPreflightResult => ({
371
+ canUnwind,
372
+ canRepayRequestedSlice,
373
+ canRepayFully: canRepayRequestedSlice,
374
+ reason,
375
+ requestedUnwindQty: unwindQtyRequested,
376
+ effectiveUnwindQty: effectiveQty,
377
+ writerPositionAddress: String(writerPositionAddress),
378
+ writerRepaymentAccount: String(writerRepaymentAddress),
379
+ collateralVaultAddress: String(resolved.collateralVault),
380
+ loans: [],
381
+ summary: emptySummary(),
382
+ });
383
+
384
+ if (unwindQtyRequested <= 0n) {
385
+ return baseResult(false, false, "unwindQty must be > 0", 0n);
386
+ }
387
+ if (unwindQtyRequested > unsoldQty) {
388
+ return baseResult(false, false, "unwindQty exceeds writer unsold quantity", 0n);
389
+ }
390
+ if (unwindQtyEffective <= 0n) {
391
+ return baseResult(
392
+ false,
393
+ false,
394
+ "Pool escrow LONG balance or your SHORT token balance is insufficient for any unwind. Refresh positions or unwind a smaller amount.",
395
+ 0n
396
+ );
397
+ }
398
+
399
+ const unwindQty = unwindQtyEffective;
400
+
401
+ const slotNow = toBigInt(currentSlot);
402
+ const protocolFeeBps = BigInt(vault.protocolFeeBps);
403
+ const loanBreakdown: Array<UnwindLoanBreakdown> = [];
404
+
405
+ const includePoolLoansForRepay = writerPositionHasOngoingPoolLoanDebt(writerPosition);
406
+
407
+ const vaultLoanCandidates = loans.filter(
408
+ (loan) => toAddress(loan.data.vault) === vaultPdaAddress && Number(loan.data.status) === 1
409
+ );
410
+
411
+ const writerPositionAddr = String(writerPositionAddress);
412
+ const matchingLoans = includePoolLoansForRepay
413
+ ? vaultLoanCandidates.filter(
414
+ (loan) => toAddress(loan.data.writerPosition) === writerPositionAddr
415
+ )
416
+ : [];
417
+
418
+ // The on-chain `sync_collateral_pool_debt` requires the writer-position-matching loans in
419
+ // `remaining_accounts` to be exactly `writer.active_loan_count`. Dev clusters can leave
420
+ // orphan PoolLoan PDAs with status==1 not tracked by the writer, so when matchingLoans >
421
+ // activeLoanCount we sort by nonce DESC and take the top-N (most recent borrows are the
422
+ // ones the writer accumulator tracks), then verify the principal sum matches
423
+ // writer.borrowed_principal as a safety check.
424
+ const sortByNonceDesc = (
425
+ a: (typeof matchingLoans)[number],
426
+ b: (typeof matchingLoans)[number]
427
+ ) => {
428
+ if (a.data.nonce > b.data.nonce) return -1;
429
+ if (a.data.nonce < b.data.nonce) return 1;
430
+ return String(a.address).localeCompare(String(b.address));
431
+ };
432
+ const positionLoans = includePoolLoansForRepay
433
+ ? matchingLoans.slice().sort(sortByNonceDesc).slice(0, writerPosition.activeLoanCount)
434
+ : [];
435
+
436
+ if (includePoolLoansForRepay) {
437
+ // #region agent log
438
+ __debugIngest("loan_selection", {
439
+ activeLoanCount: writerPosition.activeLoanCount,
440
+ matchingLoansFound: matchingLoans.length,
441
+ selectedCount: positionLoans.length,
442
+ selectedNonces: positionLoans.map((l) => Number(l.data.nonce)),
443
+ selectedPrincipalSum: positionLoans
444
+ .reduce((s, l) => s + toBigInt(l.data.principal), 0n)
445
+ .toString(),
446
+ writerBorrowedPrincipal: toBigInt(writerPosition.borrowedPrincipal).toString(),
447
+ });
448
+ // #endregion
449
+ if (positionLoans.length !== writerPosition.activeLoanCount) {
450
+ return baseResult(
451
+ false,
452
+ false,
453
+ `Pool loan set mismatch: writer tracks ${writerPosition.activeLoanCount} active loan(s) but only found ${matchingLoans.length} matching status==1 PoolLoan(s) for this writer position. Refresh positions.`,
454
+ unwindQtyEffective
455
+ );
456
+ }
457
+ const selectedPrincipalSum = positionLoans.reduce(
458
+ (sum, loan) => sum + toBigInt(loan.data.principal),
459
+ 0n
460
+ );
461
+ const writerBorrowedPrincipal = toBigInt(writerPosition.borrowedPrincipal);
462
+ if (selectedPrincipalSum !== writerBorrowedPrincipal) {
463
+ return baseResult(
464
+ false,
465
+ false,
466
+ `Active pool loan principal mismatch: top-${writerPosition.activeLoanCount} loans by nonce sum to ${selectedPrincipalSum} but writer.borrowed_principal=${writerBorrowedPrincipal}. On-chain state is inconsistent (extra orphan PoolLoans for this writer). Use the keeper rescue path.`,
467
+ unwindQtyEffective
468
+ );
469
+ }
470
+ }
471
+
472
+ if (includePoolLoansForRepay) {
473
+ for (const loan of positionLoans) {
474
+ const principal = toBigInt(loan.data.principal);
475
+ const accruedInterest = toBigInt(loan.data.accruedInterest);
476
+ const accruedProtocolFees = toBigInt(loan.data.accruedProtocolFees);
477
+ const rateBps = BigInt(loan.data.rateBps);
478
+ const lastUpdateSlot = toBigInt(loan.data.lastUpdateSlot);
479
+ const slotsElapsed = slotNow > lastUpdateSlot ? slotNow - lastUpdateSlot : 0n;
480
+ const newlyAccruedInterest =
481
+ slotsElapsed > 0n ? (principal * rateBps * slotsElapsed) / BPS_DENOMINATOR / SLOTS_PER_YEAR : 0n;
482
+ const newlyAccruedProtocolFees =
483
+ slotsElapsed > 0n
484
+ ? (principal * protocolFeeBps * slotsElapsed) / BPS_DENOMINATOR / SLOTS_PER_YEAR
485
+ : 0n;
486
+ const totalInterest = accruedInterest + newlyAccruedInterest;
487
+ const totalProtocolFees = accruedProtocolFees + newlyAccruedProtocolFees;
488
+ const totalOwed = principal + totalInterest + totalProtocolFees;
489
+
490
+ loanBreakdown.push({
491
+ loanAddress: String(loan.address),
492
+ principal,
493
+ accruedInterest,
494
+ accruedProtocolFees,
495
+ newlyAccruedInterest,
496
+ newlyAccruedProtocolFees,
497
+ totalInterest,
498
+ totalProtocolFees,
499
+ totalOwed,
500
+ });
501
+ }
502
+ }
503
+
504
+ const totals = loanBreakdown.reduce(
505
+ (acc, item) => ({
506
+ principal: acc.principal + item.principal,
507
+ interest: acc.interest + item.totalInterest,
508
+ fees: acc.fees + item.totalProtocolFees,
509
+ owed: acc.owed + item.totalOwed,
510
+ }),
511
+ { principal: 0n, interest: 0n, fees: 0n, owed: 0n }
512
+ );
513
+
514
+ const collateralVaultAvailable = await fetchTokenAmount(
515
+ params.rpc,
516
+ resolved.collateralVault!
517
+ );
518
+
519
+ const writtenQty = toBigInt(writerPosition.writtenQty);
520
+ const proportionalPrincipal = proportionalAmount(totals.principal, unwindQty, writtenQty);
521
+ const proportionalInterest = proportionalAmount(totals.interest, unwindQty, writtenQty);
522
+ const proportionalProtocolFees = proportionalAmount(totals.fees, unwindQty, writtenQty);
523
+ const proportionalTotalOwed =
524
+ proportionalPrincipal + proportionalInterest + proportionalProtocolFees;
525
+
526
+ const collateralDeposited = toBigInt(writerPosition.collateralDeposited);
527
+ const proportionalCollateralShare = proportionalAmount(
528
+ collateralDeposited,
529
+ unwindQty,
530
+ writtenQty
531
+ );
532
+
533
+ // Theta balance after realize_theta(pool.acc_theta_per_oi_fp).
534
+ const thetaAvailable = computeThetaBalance(writerPosition, optionPoolData.accThetaPerOiFp);
535
+
536
+ // Theta can only offset debt when premium_vault.mint == omlp_vault.mint.
537
+ // In OPX, premium_vault.mint == option_pool.underlying_mint (enforced via
538
+ // `buyer_payment_account.mint == option_pool.underlying_mint`), and the
539
+ // OMLP vault is derived for the collateral mint. They match when the
540
+ // collateral pool's collateral_mint equals the option pool's underlying_mint.
541
+ const premiumMintMatchesCollateralMint =
542
+ toAddress(optionPoolData.underlyingMint) === toAddress(collateralPoolData.collateralMint);
543
+
544
+ const thetaToDebt = premiumMintMatchesCollateralMint
545
+ ? minBigInt(thetaAvailable, proportionalTotalOwed)
546
+ : 0n;
547
+ const collateralToDebt = proportionalTotalOwed - thetaToDebt;
548
+
549
+ // Full-repay-or-revert: writer's proportional collateral share MUST cover
550
+ // the residual debt after theta. A non-zero `collateralClaimShortfall`
551
+ // means the writer is insolvent on this slice and the keeper rescue path
552
+ // is required.
553
+ const collateralClaimShortfall =
554
+ collateralToDebt > proportionalCollateralShare
555
+ ? collateralToDebt - proportionalCollateralShare
556
+ : 0n;
557
+ // On-chain, `collateral_vault` pays `collateralToDebt` then returns
558
+ // `proportionalCollateralShare - collateralToDebt` to the writer — total
559
+ // `proportionalCollateralShare`. Ignore gaps ≤ dust epsilon (see
560
+ // `collateralVaultUnwindDustEpsilon`).
561
+ const rawCollateralVaultShortfall =
562
+ proportionalCollateralShare > collateralVaultAvailable
563
+ ? proportionalCollateralShare - collateralVaultAvailable
564
+ : 0n;
565
+ const vaultDustEps = collateralVaultUnwindDustEpsilon(proportionalCollateralShare);
566
+ const collateralVaultShortfall =
567
+ rawCollateralVaultShortfall > vaultDustEps ? rawCollateralVaultShortfall : 0n;
568
+
569
+ const returnableCollateral =
570
+ proportionalCollateralShare > collateralToDebt
571
+ ? proportionalCollateralShare - collateralToDebt
572
+ : 0n;
573
+
574
+ const needsRescue = collateralClaimShortfall > 0n || collateralVaultShortfall > 0n;
575
+ const canRepayRequestedSlice = !needsRescue;
576
+
577
+ let reason: string | undefined;
578
+ if (collateralClaimShortfall > 0n) {
579
+ reason =
580
+ "Writer position is underwater: realized theta + proportional collateral cannot repay the debt slice. Call the keeper rescue path (liquidate_writer_position_rescue).";
581
+ } else if (collateralVaultShortfall > 0n) {
582
+ reason =
583
+ "Collateral vault is too drained to settle this slice. Refresh positions and retry, or call the keeper rescue path.";
584
+ }
585
+
586
+ return {
587
+ canUnwind: true,
588
+ canRepayRequestedSlice,
589
+ canRepayFully: canRepayRequestedSlice,
590
+ reason,
591
+ requestedUnwindQty: unwindQtyRequested,
592
+ effectiveUnwindQty: unwindQtyEffective,
593
+ writerPositionAddress: String(writerPositionAddress),
594
+ writerRepaymentAccount: String(writerRepaymentAddress),
595
+ collateralVaultAddress: String(resolved.collateralVault),
596
+ loans: loanBreakdown,
597
+ summary: {
598
+ activeLoanCount: loanBreakdown.length,
599
+ totalPrincipal: totals.principal,
600
+ totalInterest: totals.interest,
601
+ totalProtocolFees: totals.fees,
602
+ totalOwed: totals.owed,
603
+ proportionalPrincipal,
604
+ proportionalInterest,
605
+ proportionalProtocolFees,
606
+ proportionalTotalOwed,
607
+ proportionalCollateralShare,
608
+ returnableCollateral,
609
+ collateralVaultAvailable,
610
+ thetaAvailable,
611
+ thetaToDebt,
612
+ collateralToDebt,
613
+ premiumMintMatchesCollateralMint,
614
+ collateralClaimShortfall,
615
+ collateralVaultShortfall,
616
+ needsRescue,
617
+ },
618
+ };
619
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "es2022",
4
+ "target": "es2020",
5
+ "moduleResolution": "node",
6
+ "strict": true,
7
+ "declaration": true,
8
+ "outDir": "dist",
9
+ "skipLibCheck": true
10
+ },
11
+ "include": ["**/*.ts"],
12
+ "exclude": ["dist", "node_modules"]
13
+ }