@exponent-labs/market-three-math 0.1.8

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 (63) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +197 -0
  3. package/build/addLiquidity.d.ts +67 -0
  4. package/build/addLiquidity.js +269 -0
  5. package/build/addLiquidity.js.map +1 -0
  6. package/build/bisect.d.ts +1 -0
  7. package/build/bisect.js +62 -0
  8. package/build/bisect.js.map +1 -0
  9. package/build/index.d.ts +24 -0
  10. package/build/index.js +76 -0
  11. package/build/index.js.map +1 -0
  12. package/build/liquidityHistogram.d.ts +50 -0
  13. package/build/liquidityHistogram.js +162 -0
  14. package/build/liquidityHistogram.js.map +1 -0
  15. package/build/quote.d.ts +18 -0
  16. package/build/quote.js +106 -0
  17. package/build/quote.js.map +1 -0
  18. package/build/swap-v2.d.ts +20 -0
  19. package/build/swap-v2.js +261 -0
  20. package/build/swap-v2.js.map +1 -0
  21. package/build/swap.d.ts +15 -0
  22. package/build/swap.js +249 -0
  23. package/build/swap.js.map +1 -0
  24. package/build/swapLegacy.d.ts +16 -0
  25. package/build/swapLegacy.js +229 -0
  26. package/build/swapLegacy.js.map +1 -0
  27. package/build/swapV2.d.ts +11 -0
  28. package/build/swapV2.js +406 -0
  29. package/build/swapV2.js.map +1 -0
  30. package/build/types.d.ts +73 -0
  31. package/build/types.js +9 -0
  32. package/build/types.js.map +1 -0
  33. package/build/utils.d.ts +119 -0
  34. package/build/utils.js +219 -0
  35. package/build/utils.js.map +1 -0
  36. package/build/utilsV2.d.ts +88 -0
  37. package/build/utilsV2.js +180 -0
  38. package/build/utilsV2.js.map +1 -0
  39. package/build/withdrawLiquidity.d.ts +8 -0
  40. package/build/withdrawLiquidity.js +174 -0
  41. package/build/withdrawLiquidity.js.map +1 -0
  42. package/build/ytTrades.d.ts +106 -0
  43. package/build/ytTrades.js +292 -0
  44. package/build/ytTrades.js.map +1 -0
  45. package/build/ytTradesLegacy.d.ts +106 -0
  46. package/build/ytTradesLegacy.js +292 -0
  47. package/build/ytTradesLegacy.js.map +1 -0
  48. package/examples/.env.example +1 -0
  49. package/examples/test-histogram-simple.ts +172 -0
  50. package/examples/test-histogram.ts +112 -0
  51. package/package.json +26 -0
  52. package/src/addLiquidity.ts +384 -0
  53. package/src/bisect.ts +72 -0
  54. package/src/index.ts +74 -0
  55. package/src/liquidityHistogram.ts +192 -0
  56. package/src/quote.ts +128 -0
  57. package/src/swap.ts +299 -0
  58. package/src/swapLegacy.ts +272 -0
  59. package/src/types.ts +80 -0
  60. package/src/utils.ts +235 -0
  61. package/src/withdrawLiquidity.ts +240 -0
  62. package/src/ytTrades.ts +419 -0
  63. package/tsconfig.json +17 -0
@@ -0,0 +1,240 @@
1
+ import { LpPositionCLMM, MarketThree, Ticks } from "@exponent-labs/exponent-fetcher"
2
+
3
+ import { getSuccessorTickByIdx } from "./utils"
4
+
5
+ /**
6
+ * TypeScript version of project_anchor_shares_to_current_ticks from Rust
7
+ * Project anchor shares to current ticks, splitting as necessary based on tick splits
8
+ *
9
+ * This function handles the case where ticks have been split since the position was created.
10
+ * It recursively splits shares proportionally based on the spot price ranges.
11
+ *
12
+ * @param ticks - The ticks state containing the tick tree (must include lastSplitEpoch data)
13
+ * @param rootShares - The original share trackers to project
14
+ * @returns Array of projected principal shares
15
+ *
16
+ * Note: Requires TicksExtended with lastSplitEpoch and emissions data.
17
+ * The current exponent-fetcher deserializer would need to be updated to extract these fields.
18
+ */
19
+ type ShareTracker = LpPositionCLMM["shareTrackers"][0]
20
+ function projectAnchorSharesToCurrentTicks(ticks: Ticks, rootShares: ShareTracker[]): ShareTracker[] {
21
+ const SENTINEL = 0xffffffff // Sentinel value for tree traversal (matches Rust implementation)
22
+
23
+ const stack = [...rootShares] // Clone the trackers array
24
+ const newShares: ShareTracker[] = [] // This will hold the leaf shares
25
+
26
+ while (stack.length > 0) {
27
+ const principalShare = stack.pop()!
28
+
29
+ // Find the tick node for this share
30
+ const tickNode = ticks.ticksTree.at(principalShare.tickIdx - 1) ?? null
31
+
32
+ if (!tickNode) {
33
+ //? Tick node not found for tickIdx
34
+ continue
35
+ }
36
+
37
+ const lastSplitEpoch = tickNode.lastSplitEpoch
38
+
39
+ // Check if we need to split this share (if the tick has been split since this share was created)
40
+ if (principalShare.splitEpoch < lastSplitEpoch) {
41
+ const rightIndex = principalShare.rightTickIdx
42
+
43
+ if (rightIndex !== SENTINEL) {
44
+ // Find the successor tick (the split point)
45
+ const splitedIndex = getSuccessorTickByIdx(ticks, principalShare.tickIdx)
46
+
47
+ if (splitedIndex === null) {
48
+ //? No successor tick found for splitting
49
+ continue
50
+ }
51
+
52
+ const tickSpotPrice = tickNode.impliedRate
53
+ const rightTickNode = ticks.ticksTree.at(rightIndex - 1) ?? null
54
+ const splitedTickNode = ticks.ticksTree.at(splitedIndex - 1) ?? null
55
+
56
+ if (!rightTickNode || !splitedTickNode) {
57
+ //? Could not find right or split tick nodes
58
+ continue
59
+ }
60
+
61
+ // Calculate the proportions based on spot price ranges
62
+ const splitedFullRange = rightTickNode.impliedRate - tickSpotPrice
63
+ const currentSplitRange = splitedTickNode.impliedRate - tickSpotPrice
64
+
65
+ // Calculate how much LP share goes to the left portion
66
+ const leftShare = Math.floor(Number(principalShare.lpShare) * (currentSplitRange / splitedFullRange))
67
+
68
+ // Create new emission trackers with staged reset to 0
69
+ const newEmissions: ShareTracker["emissions"] = principalShare.emissions.map((tracker) => ({
70
+ staged: 0n,
71
+ lastSeenIndex: tracker.lastSeenIndex,
72
+ }))
73
+
74
+ // Calculate the migrated share (right portion)
75
+ const migratedShare = principalShare.lpShare - BigInt(leftShare)
76
+
77
+ // Update the current share to be the left portion
78
+ principalShare.lpShare = BigInt(leftShare)
79
+ principalShare.rightTickIdx = splitedIndex
80
+
81
+ // Push the right portion back onto the stack for further processing
82
+ stack.push({
83
+ tickIdx: splitedIndex,
84
+ rightTickIdx: rightIndex,
85
+ splitEpoch: principalShare.splitEpoch,
86
+ lpShare: migratedShare,
87
+ emissions: newEmissions,
88
+ })
89
+
90
+ // Update the split epoch to mark this share as processed
91
+ principalShare.splitEpoch = lastSplitEpoch
92
+ } else {
93
+ // Error: we have a split range but no right index
94
+ //? No right index for split range
95
+ continue
96
+ }
97
+ }
98
+
99
+ // Add the processed share to the result
100
+ newShares.push(principalShare)
101
+ }
102
+
103
+ // Reverse to maintain the original order (since we used a stack)
104
+ newShares.reverse()
105
+
106
+ return newShares
107
+ }
108
+
109
+ /**
110
+ * TypeScript version of update_lp_position_shares from Rust
111
+ * Recompute and update the LP position's share trackers to reflect current ticks state
112
+ *
113
+ * @param market - The market account containing emission indices
114
+ * @param ticks - The ticks state (must be TicksExtended with emission data)
115
+ * @param position - The LP position with share trackers to update
116
+ *
117
+ */
118
+ function updateLpPositionShares(
119
+ marketEmissions: MarketThree["emissions"],
120
+ ticks: Ticks,
121
+ position: LpPositionCLMM,
122
+ ): LpPositionCLMM {
123
+ // Get market emission indices (last seen indices from market's emission trackers)
124
+ // Market has emissions.trackers array with lpShareIndex for each emission
125
+ const marketEmissionIndices: number[] = marketEmissions.trackers.map((tracker) => tracker.lpShareIndex)
126
+
127
+ // Project anchor shares to current ticks
128
+ // This handles tick splitting that may have occurred since the position was created
129
+ const recomputedShares = projectAnchorSharesToCurrentTicks(ticks, position.shareTrackers)
130
+
131
+ // Iterate through each share and update
132
+ for (const share of recomputedShares) {
133
+ const myShares = share.lpShare
134
+
135
+ // Find the tick node in the ticks tree
136
+ const tickNode = ticks.ticksTree.at(share.tickIdx - 1) ?? null
137
+
138
+ if (!tickNode) {
139
+ //? Tick node not found for provided tickIdx
140
+ continue
141
+ }
142
+
143
+ // Update tick emissions with market emission indices
144
+ // In Rust: node_mut.value.update_tick_emissions(market_emission_indices)
145
+ for (let i = 0; i < marketEmissionIndices.length; i++) {
146
+ if (tickNode.emissions[i]) {
147
+ tickNode.emissions[i].lastSeenIndex = marketEmissionIndices[i]
148
+ }
149
+ }
150
+
151
+ // Accrue fees and emissions when removing
152
+ // In Rust: share.accrue_fees_emissions_when_remove(&node_mut.value, my_shares)
153
+ // This updates the share's emission trackers based on the tick's emission state
154
+ // and the number of LP shares
155
+ for (let i = 0; i < share.emissions.length; i++) {
156
+ const shareTracker = share.emissions[i]
157
+ const tickTracker = tickNode.emissions[i]
158
+
159
+ if (shareTracker && tickTracker) {
160
+ // Calculate accrued emissions based on index difference and LP shares
161
+ const indexDelta = tickTracker.lastSeenIndex - shareTracker.lastSeenIndex
162
+ const accruedEmissions = Number(myShares) * indexDelta
163
+
164
+ // Update staged emissions
165
+ shareTracker.staged += BigInt(Math.floor(accruedEmissions))
166
+ shareTracker.lastSeenIndex = tickTracker.lastSeenIndex
167
+ }
168
+ }
169
+
170
+ // Update split epoch
171
+ share.splitEpoch = tickNode.lastSplitEpoch
172
+ }
173
+
174
+ return {
175
+ ...position,
176
+ shareTrackers: recomputedShares,
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Calculate the total PT and SY amounts that would be removed from a position
182
+ * without actually modifying any state.
183
+ *
184
+ * TypeScript version based on Rust remove_liquidity logic
185
+ *
186
+ * @param position - The LP position to calculate removal amounts for
187
+ * @param ticks - The ticks data structure containing tick information
188
+ * @param liquidityToRemove - The amount of liquidity to remove (in L units)
189
+ * @returns Object with totalPtOut and totalSyOut
190
+ */
191
+ function calculatePtSyRemoval(
192
+ position: LpPositionCLMM,
193
+ ticks: Ticks,
194
+ liquidityToRemove: bigint,
195
+ ): { totalPtOut: bigint; totalSyOut: bigint } {
196
+ let totalPtOut = 0n
197
+ let totalSyOut = 0n
198
+
199
+ for (const share of position.shareTrackers) {
200
+ const myShares = share.lpShare
201
+ const tickNode = ticks.ticksTree.at(share.tickIdx - 1) ?? null
202
+
203
+ if (!tickNode) {
204
+ //? Tick node not found for provided tickIdx
205
+ continue
206
+ }
207
+
208
+ const supply = tickNode.principalShareSupply
209
+
210
+ // Calculate burn shares: r = l_remove / pos.L
211
+ // burn_shares = my_shares * liquidity_to_remove / position.lp_balance
212
+ const burnShares = (myShares * liquidityToRemove) / position.lpBalance
213
+
214
+ // Calculate PT output for this tick
215
+ // pt_out = principal_pt * burn_shares / supply
216
+ const ptOut = (tickNode.principalPt * burnShares) / supply
217
+
218
+ // Calculate SY output for this tick
219
+ // sy_out = principal_sy * burn_shares / supply
220
+ const syOut = (tickNode.principalSy * burnShares) / supply
221
+
222
+ totalPtOut += ptOut
223
+ totalSyOut += syOut
224
+ }
225
+
226
+ return { totalPtOut, totalSyOut }
227
+ }
228
+
229
+ /**
230
+ * Returns the total PT and SY amounts that would be removed from a position
231
+ */
232
+ export function getPtAndSyOnWithdrawLiquidity(
233
+ marketEmissions: MarketThree["emissions"],
234
+ ticks: Ticks,
235
+ position: LpPositionCLMM,
236
+ liquidityToRemove: bigint,
237
+ ): { totalPtOut: bigint; totalSyOut: bigint } {
238
+ const updatedPosition = updateLpPositionShares(marketEmissions, ticks, position)
239
+ return calculatePtSyRemoval(updatedPosition, ticks, liquidityToRemove)
240
+ }
@@ -0,0 +1,419 @@
1
+ /**
2
+ * YT (Yield Token) trade simulations
3
+ *
4
+ * YT trades are multi-step operations that involve PT trades:
5
+ * - Buy YT: Strip SY → PT+YT, then sell PT back to pool
6
+ * - Sell YT: Merge PT+YT → SY, then buy PT from pool to repay
7
+ */
8
+ import { bisectSearch2 } from "./bisect"
9
+ import { simulateSwap } from "./swap"
10
+ import { MarketThreeState, SwapDirection, SwapOutcome } from "./types"
11
+
12
+ /**
13
+ * Helper function to convert PY to SY
14
+ * @param syExchangeRate - The SY exchange rate
15
+ * @param pyAmount - The PY (PT or YT) amount
16
+ * @returns The equivalent SY amount
17
+ */
18
+ function pyToSy(syExchangeRate: number, pyAmount: number): number {
19
+ if (syExchangeRate <= 0) return 0
20
+ return Math.floor(pyAmount / syExchangeRate)
21
+ }
22
+
23
+ /**
24
+ * Helper function to convert SY to PY
25
+ * @param syExchangeRate - The SY exchange rate
26
+ * @param syAmount - The SY amount
27
+ * @returns The equivalent PY amount
28
+ */
29
+ function syToPy(syExchangeRate: number, syAmount: number): number {
30
+ return Math.floor(syAmount * syExchangeRate)
31
+ }
32
+
33
+ export interface BuyYtSimulationArgs {
34
+ /** Amount of YT desired to buy */
35
+ ytOut: number
36
+ /** SY exchange rate */
37
+ syExchangeRate: number
38
+ /** Optional spot price limit (anti-sandwich) */
39
+ priceSpotLimit?: number
40
+ }
41
+
42
+ export interface BuyYtSimulationResult {
43
+ /** Amount of YT received */
44
+ ytOut: number
45
+ /** Net SY cost to the trader */
46
+ netSyCost: number
47
+ /** Amount of SY that needs to be stripped */
48
+ syToStrip: number
49
+ /** Amount of PT received from stripping */
50
+ ptFromStrip: number
51
+ /** Amount of SY received from selling PT */
52
+ syFromPtSale: number
53
+ /** LP fee charged */
54
+ lpFee: number
55
+ /** Protocol fee charged */
56
+ protocolFee: number
57
+ /** Final spot price after the trade */
58
+ finalSpotPrice: number
59
+ }
60
+
61
+ /**
62
+ * Simulates buying YT tokens
63
+ *
64
+ * Process:
65
+ * 1. Calculate how much SY to strip to get desired YT
66
+ * 2. Strip SY → PT + YT (PT amount ≈ YT amount)
67
+ * 3. Sell PT to the pool (PtToSy direction)
68
+ * 4. Net cost = SY stripped - SY received from PT sale
69
+ *
70
+ * @param marketState - Current market state
71
+ * @param args - Simulation arguments
72
+ * @returns Simulation result with net SY cost
73
+ */
74
+ export function simulateBuyYt(marketState: MarketThreeState, args: BuyYtSimulationArgs): BuyYtSimulationResult {
75
+ const { ytOut, syExchangeRate, priceSpotLimit } = args
76
+
77
+ // Calculate how much SY needs to be stripped to get the desired YT
78
+ // Add 1 to counter-act the flooring function when converting from PY to SY
79
+ const syToStrip = pyToSy(syExchangeRate, ytOut) + 1
80
+
81
+ // Stripping gives approximately equal amounts of PT and YT
82
+ const ptFromStrip = ytOut
83
+
84
+ // Simulate selling the PT to get SY back
85
+ // Note: We use ytOut as the amount because PT out = YT out from the strip
86
+ const swapResult = simulateSwap(marketState, {
87
+ direction: SwapDirection.PtToSy,
88
+ amountIn: ytOut,
89
+ priceSpotLimit,
90
+ syExchangeRate,
91
+ isCurrentFlashSwap: true,
92
+ })
93
+
94
+ const syFromPtSale = swapResult.amountOut
95
+
96
+ // Net cost is the difference between what was stripped and what was received
97
+ const netSyCost = syToStrip - syFromPtSale
98
+
99
+ return {
100
+ ytOut,
101
+ netSyCost,
102
+ syToStrip,
103
+ ptFromStrip,
104
+ syFromPtSale,
105
+ lpFee: swapResult.lpFeeChargedOutToken,
106
+ protocolFee: swapResult.protocolFeeChargedOutToken,
107
+ finalSpotPrice: swapResult.finalSpotPrice,
108
+ }
109
+ }
110
+
111
+ export interface BuyYtWithSyInSimulationArgs {
112
+ /** Amount of SY to spend */
113
+ syIn: number
114
+ /** SY exchange rate */
115
+ syExchangeRate: number
116
+ /** Optional spot price limit (anti-sandwich) */
117
+ priceSpotLimit?: number
118
+ }
119
+
120
+ /**
121
+ * Simulates buying YT tokens given a SY input amount
122
+ *
123
+ * Process:
124
+ * 1. Strip syIn → PT + YT
125
+ * 2. Sell PT → get SY back (with price impact)
126
+ * 3. Strip SY again → more PT + YT
127
+ * 4. Repeat until convergence
128
+ * Total YT = YT₁ + YT₂ + YT₃ + ...
129
+ *
130
+ * Uses bisection search to find ytOut such that netSyCost = syIn
131
+ *
132
+ * @param marketState - Current market state
133
+ * @param args - Simulation arguments
134
+ * @returns Simulation result with calculated YT output
135
+ */
136
+ export function simulateBuyYtWithSyIn(
137
+ marketState: MarketThreeState,
138
+ args: BuyYtWithSyInSimulationArgs,
139
+ ): BuyYtSimulationResult {
140
+ const { syIn, syExchangeRate, priceSpotLimit } = args
141
+
142
+ // Lower bound: Start very low since PT recycling allows buying much more YT than naive calculation
143
+ // The actual minimum depends on PT price (how much SY we get back from selling PT)
144
+ // If PT price is high (close to 1), we get most SY back, so we can buy much more YT
145
+ // Example: With 1000 SY and PT price 0.9:
146
+ // - To get 5000 YT, strip 5000 SY → 5000 PT + 5000 YT
147
+ // - Sell 5000 PT at 0.9 → 4500 SY back
148
+ // - Net cost: 5000 - 4500 = 500 SY (way less than 1000!)
149
+ const minPossibleYt = 1
150
+
151
+ // Better initial upper bound estimate based on market liquidity
152
+ // Maximum possible YT is constrained by available PT liquidity in the market
153
+ const marketPtLiquidity = Number(marketState.financials.ptBalance)
154
+ const marketSyLiquidity = Number(marketState.financials.syBalance)
155
+
156
+ // Conservative estimate: we can't buy more YT than there's PT in the market
157
+ // High multiplier needed because PT recycling amplifies buying power dramatically
158
+ // Formula: ytOut ≈ syIn / (1 - ptPrice)
159
+ // Examples: PT@0.99 → 100x, PT@0.95 → 20x, PT@0.90 → 10x, PT@0.80 → 5x
160
+ const liquidityBasedMax = Math.min(
161
+ marketPtLiquidity * 0.9, // 90% of PT liquidity to be safe
162
+ syToPy(syExchangeRate, syIn) * 100, // 100x to handle PT prices up to 0.99
163
+ )
164
+
165
+ // Start with a reasonable initial guess (10x covers most realistic scenarios)
166
+ let maxPossibleYt = Math.min(syToPy(syExchangeRate, syIn) * 10, liquidityBasedMax)
167
+ let foundUpperBound = false
168
+ let lastValidCost = 0
169
+
170
+ // Use exponential search with better growth rate
171
+ for (let attempt = 0; attempt < 12; attempt++) {
172
+ try {
173
+ const syToStrip = pyToSy(syExchangeRate, maxPossibleYt) + 1
174
+
175
+ const swapResult = simulateSwap(marketState, {
176
+ direction: SwapDirection.PtToSy,
177
+ amountIn: maxPossibleYt,
178
+ priceSpotLimit,
179
+ syExchangeRate,
180
+ isCurrentFlashSwap: true,
181
+ })
182
+
183
+ const syFromPtSale = swapResult.amountOut
184
+ const netSyCost = syToStrip - syFromPtSale
185
+
186
+ // If this costs more than syIn, we found our upper bound
187
+ if (netSyCost > syIn) {
188
+ foundUpperBound = true
189
+ break
190
+ }
191
+
192
+ lastValidCost = netSyCost
193
+
194
+ // Use adaptive growth rate based on how far we are from target
195
+ const costRatio = syIn / Math.max(netSyCost, 1)
196
+ const growthFactor = attempt < 3 ? 2.0 : Math.min(1.5, 1 + costRatio * 0.3)
197
+
198
+ maxPossibleYt *= growthFactor
199
+
200
+ // Don't exceed liquidity constraints
201
+ if (maxPossibleYt > liquidityBasedMax) {
202
+ maxPossibleYt = liquidityBasedMax
203
+ foundUpperBound = true
204
+ break
205
+ }
206
+ } catch (error) {
207
+ // If simulation fails, we've exceeded market capacity
208
+ // Use last valid value with small buffer, ensuring it's > minPossibleYt
209
+ if (lastValidCost > 0) {
210
+ // Interpolate to find better upper bound
211
+ const estimatedMax = maxPossibleYt * 0.7 * (syIn / lastValidCost)
212
+ maxPossibleYt = Math.max(estimatedMax, minPossibleYt * 1.2)
213
+ } else {
214
+ // No valid cost yet, use a conservative upper bound
215
+ maxPossibleYt = Math.max(maxPossibleYt * 0.8, minPossibleYt * 1.5)
216
+ }
217
+ foundUpperBound = true
218
+ break
219
+ }
220
+ }
221
+
222
+ if (!foundUpperBound) {
223
+ throw new Error(`Could not find upper bound for YT amount. Market may have unusual price dynamics.`)
224
+ }
225
+
226
+ // Final safety check: ensure maxPossibleYt > minPossibleYt
227
+ if (maxPossibleYt <= minPossibleYt) {
228
+ maxPossibleYt = minPossibleYt * 2
229
+ }
230
+
231
+ // Use bisection search to find the ytOut that results in netSyCost = syIn
232
+ // Adaptive epsilon based on input size for better precision scaling
233
+ const adaptiveEpsilon = Math.max(0.01, syIn * 0.0001)
234
+
235
+ // Reduce max iterations since we have better bounds
236
+ const maxIterations = 10000
237
+
238
+ // Debug info for bounds validation
239
+ if (maxPossibleYt <= minPossibleYt) {
240
+ throw new Error(
241
+ `Invalid bisection bounds for buy YT. ` +
242
+ `Min: ${minPossibleYt}, Max: ${maxPossibleYt}, ` +
243
+ `SyIn: ${syIn}, SyExchangeRate: ${syExchangeRate}, ` +
244
+ `MarketPtBalance: ${marketPtLiquidity}, MarketSyBalance: ${marketSyLiquidity}`,
245
+ )
246
+ }
247
+
248
+ const ytOut = bisectSearch2(
249
+ (ytGuess: number) => {
250
+ // Calculate the cost for this ytGuess
251
+ const syToStrip = pyToSy(syExchangeRate, ytGuess) + 1
252
+
253
+ const swapResult = simulateSwap(marketState, {
254
+ direction: SwapDirection.PtToSy,
255
+ amountIn: ytGuess,
256
+ priceSpotLimit,
257
+ syExchangeRate,
258
+ isCurrentFlashSwap: true,
259
+ })
260
+
261
+ const syFromPtSale = swapResult.amountOut
262
+ const netSyCost = syToStrip - syFromPtSale
263
+
264
+ // Return the difference between actual cost and target syIn
265
+ return netSyCost - syIn
266
+ },
267
+ minPossibleYt,
268
+ maxPossibleYt,
269
+ adaptiveEpsilon,
270
+ maxIterations,
271
+ )
272
+
273
+ if (ytOut === null) {
274
+ throw new Error(
275
+ `Failed to converge on correct YT amount. ` +
276
+ `Search range: [${minPossibleYt}, ${maxPossibleYt}], ` +
277
+ `Epsilon: ${adaptiveEpsilon}`,
278
+ )
279
+ }
280
+
281
+ // Now calculate the full result with the found ytOut
282
+ return simulateBuyYt(marketState, {
283
+ ytOut,
284
+ syExchangeRate,
285
+ priceSpotLimit,
286
+ })
287
+ }
288
+
289
+ export interface SellYtSimulationArgs {
290
+ /** Amount of YT to sell */
291
+ ytIn: number
292
+ /** SY exchange rate */
293
+ syExchangeRate: number
294
+ /** Optional spot price limit (anti-sandwich) */
295
+ priceSpotLimit?: number
296
+ }
297
+
298
+ export interface SellYtSimulationResult {
299
+ /** Amount of YT sold */
300
+ ytIn: number
301
+ /** Net SY received by the trader */
302
+ netSyReceived: number
303
+ /** Amount of SY received from merging PT + YT */
304
+ syFromMerge: number
305
+ /** Amount of SY spent buying PT back */
306
+ sySpentOnPt: number
307
+ /** LP fee charged */
308
+ lpFee: number
309
+ /** Protocol fee charged */
310
+ protocolFee: number
311
+ /** Final spot price after the trade */
312
+ finalSpotPrice: number
313
+ }
314
+
315
+ /**
316
+ * Simulates selling YT tokens
317
+ *
318
+ * Process:
319
+ * 1. Merge PT + YT → SY (receive SY from the merge)
320
+ * 2. Buy PT from the pool to repay the borrowed PT (SyToPt direction)
321
+ * 3. Net received = SY from merge - SY spent on PT
322
+ *
323
+ * Note: The market must have at least 2x the YT amount in PT liquidity
324
+ * because the trader borrows PT, which is then bought back.
325
+ *
326
+ * @param marketState - Current market state
327
+ * @param args - Simulation arguments
328
+ * @returns Simulation result with net SY received
329
+ */
330
+ export function simulateSellYt(marketState: MarketThreeState, args: SellYtSimulationArgs): SellYtSimulationResult {
331
+ const { ytIn, syExchangeRate, priceSpotLimit } = args
332
+
333
+ // Check if there's sufficient PT liquidity
334
+ // The market needs at least 2x the YT amount in PT
335
+ if (marketState.financials.ptBalance < ytIn * 2) {
336
+ throw new Error(
337
+ `Insufficient PT liquidity in the market. Required: ${ytIn * 2}, Available: ${marketState.financials.ptBalance}`,
338
+ )
339
+ }
340
+
341
+ // Merging PT + YT gives back the original SY
342
+ // The amount of PT needed equals ytIn (1:1 ratio)
343
+ const syFromMerge = pyToSy(syExchangeRate, ytIn)
344
+
345
+ const ptNeeded = ytIn
346
+
347
+ const upperBoundSwap = simulateSwap(marketState, {
348
+ direction: SwapDirection.SyToPt,
349
+ amountIn: syFromMerge,
350
+ priceSpotLimit,
351
+ syExchangeRate,
352
+ isCurrentFlashSwap: true,
353
+ })
354
+
355
+ // Check that we can buy enough PT from CLMM
356
+ if (upperBoundSwap.amountOut < ptNeeded) {
357
+ throw new Error(
358
+ `Cannot buy enough PT with available SY. Need ${ytIn} PT but can only afford ${upperBoundSwap.amountOut} PT`,
359
+ )
360
+ }
361
+
362
+ // Better initial bounds for bisection
363
+ // We know the upper bound from the check above, and we can estimate a better lower bound
364
+ // based on the current price
365
+ const estimatedLowerBound = Math.min(syFromMerge * 0.5, syFromMerge * 0.9) // Start from 50% of max, but not too close to upper
366
+
367
+ // Adaptive epsilon based on PT needed
368
+ const adaptiveEpsilon = Math.max(0.01, ptNeeded * 0.0001)
369
+
370
+ // Safety check: ensure lower bound is less than upper bound
371
+ const safeLowerBound = Math.min(estimatedLowerBound, syFromMerge * 0.95)
372
+ const safeUpperBound = syFromMerge
373
+
374
+ if (safeLowerBound >= safeUpperBound) {
375
+ throw new Error(`Invalid bisection bounds for sell YT. Lower: ${safeLowerBound}, Upper: ${safeUpperBound}`)
376
+ }
377
+
378
+ const syToSpend = bisectSearch2(
379
+ (syGuess: number) => {
380
+ const swapResult = simulateSwap(marketState, {
381
+ direction: SwapDirection.SyToPt,
382
+ amountIn: syGuess,
383
+ priceSpotLimit,
384
+ syExchangeRate,
385
+ isCurrentFlashSwap: true,
386
+ })
387
+
388
+ return swapResult.amountOut - ptNeeded
389
+ },
390
+ safeLowerBound,
391
+ safeUpperBound,
392
+ adaptiveEpsilon,
393
+ 100, // Reduced iterations with better bounds
394
+ )
395
+
396
+ if (syToSpend === null) {
397
+ throw new Error("Failed to converge on correct SY amount using bisection search")
398
+ }
399
+
400
+ const swapResult = simulateSwap(marketState, {
401
+ direction: SwapDirection.SyToPt,
402
+ amountIn: syToSpend,
403
+ priceSpotLimit,
404
+ syExchangeRate,
405
+ isCurrentFlashSwap: true,
406
+ })
407
+
408
+ const netSyReceived = syFromMerge - swapResult.amountInConsumed
409
+
410
+ return {
411
+ ytIn,
412
+ netSyReceived,
413
+ syFromMerge,
414
+ sySpentOnPt: swapResult.amountInConsumed,
415
+ lpFee: swapResult.lpFeeChargedOutToken,
416
+ protocolFee: swapResult.protocolFeeChargedOutToken,
417
+ finalSpotPrice: swapResult.finalSpotPrice,
418
+ }
419
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "rootDir": "src",
4
+ "sourceMap": true,
5
+ "incremental": true /* Save .tsbuildinfo files to allow for incremental compilation of projects. */,
6
+ "composite": true /* Enable constraints that allow a TypeScript project to be used with project references. */,
7
+ "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
8
+ "module": "CommonJS" /* Specify what module code is generated. */,
9
+ "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */,
10
+ "outDir": "./build" /* Specify an output folder for all emitted files. */,
11
+ "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
12
+ "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
13
+ "skipLibCheck": true /* Skip type checking all .d.ts files. */
14
+ },
15
+ "include": ["src"],
16
+ "exclude": ["build", "node_modules", "**/*.test.ts"]
17
+ }