@exponent-labs/market-three-math 0.1.8 → 0.9.1

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 (55) hide show
  1. package/build/addLiquidity.d.ts +65 -4
  2. package/build/addLiquidity.js +762 -36
  3. package/build/addLiquidity.js.map +1 -1
  4. package/build/bisect.d.ts +11 -0
  5. package/build/bisect.js +22 -12
  6. package/build/bisect.js.map +1 -1
  7. package/build/index.d.ts +5 -4
  8. package/build/index.js +14 -7
  9. package/build/index.js.map +1 -1
  10. package/build/liquidityHistogram.d.ts +6 -1
  11. package/build/liquidityHistogram.js +57 -12
  12. package/build/liquidityHistogram.js.map +1 -1
  13. package/build/quote.d.ts +1 -1
  14. package/build/quote.js +70 -84
  15. package/build/quote.js.map +1 -1
  16. package/build/swap.js +36 -18
  17. package/build/swap.js.map +1 -1
  18. package/build/swapV2.d.ts +6 -1
  19. package/build/swapV2.js +394 -52
  20. package/build/swapV2.js.map +1 -1
  21. package/build/types.d.ts +51 -0
  22. package/build/utils.d.ts +8 -2
  23. package/build/utils.js +37 -19
  24. package/build/utils.js.map +1 -1
  25. package/build/utilsV2.d.ts +9 -0
  26. package/build/utilsV2.js +131 -9
  27. package/build/utilsV2.js.map +1 -1
  28. package/build/withdrawLiquidity.js +12 -7
  29. package/build/withdrawLiquidity.js.map +1 -1
  30. package/build/ytTrades.d.ts +7 -0
  31. package/build/ytTrades.js +166 -146
  32. package/build/ytTrades.js.map +1 -1
  33. package/build/ytTradesLegacy.js +3 -4
  34. package/build/ytTradesLegacy.js.map +1 -1
  35. package/package.json +2 -2
  36. package/src/addLiquidity.ts +1012 -38
  37. package/src/bisect.ts +22 -11
  38. package/src/index.ts +11 -5
  39. package/src/liquidityHistogram.ts +54 -9
  40. package/src/quote.ts +73 -95
  41. package/src/swap.ts +35 -19
  42. package/src/swapV2.ts +999 -0
  43. package/src/types.ts +55 -0
  44. package/src/utils.ts +24 -3
  45. package/src/utilsV2.ts +337 -0
  46. package/src/withdrawLiquidity.ts +12 -6
  47. package/src/ytTrades.ts +191 -172
  48. package/src/ytTradesLegacy.ts +419 -0
  49. package/build/swap-v2.d.ts +0 -20
  50. package/build/swap-v2.js +0 -261
  51. package/build/swap-v2.js.map +0 -1
  52. package/build/swapLegacy.d.ts +0 -16
  53. package/build/swapLegacy.js +0 -229
  54. package/build/swapLegacy.js.map +0 -1
  55. package/src/swapLegacy.ts +0 -272
package/src/ytTrades.ts CHANGED
@@ -6,20 +6,31 @@
6
6
  * - Sell YT: Merge PT+YT → SY, then buy PT from pool to repay
7
7
  */
8
8
  import { bisectSearch2 } from "./bisect"
9
- import { simulateSwap } from "./swap"
10
- import { MarketThreeState, SwapDirection, SwapOutcome } from "./types"
9
+ import { simulateSwap, simulateSwapExactOut } from "./swapV2"
10
+ import { MarketThreeState, SwapDirection, SwapOutcomeV2 } from "./types"
11
11
 
12
12
  /**
13
- * Helper function to convert PY to SY
14
- * @param syExchangeRate - The SY exchange rate
13
+ * Helper function to convert PY to SY (floor)
14
+ * @param syExchangeRate - The SY exchange rate (SY value in terms of underlying)
15
15
  * @param pyAmount - The PY (PT or YT) amount
16
- * @returns The equivalent SY amount
16
+ * @returns The equivalent SY amount (floored)
17
17
  */
18
18
  function pyToSy(syExchangeRate: number, pyAmount: number): number {
19
19
  if (syExchangeRate <= 0) return 0
20
20
  return Math.floor(pyAmount / syExchangeRate)
21
21
  }
22
22
 
23
+ /**
24
+ * Helper function to convert PY to SY (ceil) - for when you need AT LEAST this much SY
25
+ * @param syExchangeRate - The SY exchange rate
26
+ * @param pyAmount - The PY (PT or YT) amount
27
+ * @returns The equivalent SY amount (ceiled)
28
+ */
29
+ function pyToSyCeil(syExchangeRate: number, pyAmount: number): number {
30
+ if (syExchangeRate <= 0) return 0
31
+ return Math.ceil(pyAmount / syExchangeRate)
32
+ }
33
+
23
34
  /**
24
35
  * Helper function to convert SY to PY
25
36
  * @param syExchangeRate - The SY exchange rate
@@ -75,14 +86,13 @@ export function simulateBuyYt(marketState: MarketThreeState, args: BuyYtSimulati
75
86
  const { ytOut, syExchangeRate, priceSpotLimit } = args
76
87
 
77
88
  // 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
89
+ // Use ceiling to ensure we strip enough SY
90
+ const syToStrip = pyToSyCeil(syExchangeRate, ytOut)
80
91
 
81
- // Stripping gives approximately equal amounts of PT and YT
92
+ // Stripping gives equal amounts of PT and YT (1:1 ratio based on underlying value)
82
93
  const ptFromStrip = ytOut
83
94
 
84
95
  // 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
96
  const swapResult = simulateSwap(marketState, {
87
97
  direction: SwapDirection.PtToSy,
88
98
  amountIn: ytOut,
@@ -117,6 +127,31 @@ export interface BuyYtWithSyInSimulationArgs {
117
127
  priceSpotLimit?: number
118
128
  }
119
129
 
130
+ /**
131
+ * Helper to calculate net SY cost for a given YT amount
132
+ */
133
+ function calculateNetSyCost(
134
+ marketState: MarketThreeState,
135
+ ytAmount: number,
136
+ syExchangeRate: number,
137
+ priceSpotLimit?: number,
138
+ ): { netSyCost: number; syToStrip: number; syFromPtSale: number } {
139
+ const syToStrip = pyToSyCeil(syExchangeRate, ytAmount)
140
+
141
+ const swapResult = simulateSwap(marketState, {
142
+ direction: SwapDirection.PtToSy,
143
+ amountIn: ytAmount,
144
+ priceSpotLimit,
145
+ syExchangeRate,
146
+ isCurrentFlashSwap: true,
147
+ })
148
+
149
+ const syFromPtSale = swapResult.amountOut
150
+ const netSyCost = syToStrip - syFromPtSale
151
+
152
+ return { netSyCost, syToStrip, syFromPtSale }
153
+ }
154
+
120
155
  /**
121
156
  * Simulates buying YT tokens given a SY input amount
122
157
  *
@@ -139,130 +174,131 @@ export function simulateBuyYtWithSyIn(
139
174
  ): BuyYtSimulationResult {
140
175
  const { syIn, syExchangeRate, priceSpotLimit } = args
141
176
 
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!)
177
+ if (syIn <= 0) {
178
+ throw new Error("syIn must be positive")
179
+ }
180
+
181
+ if (syExchangeRate <= 0) {
182
+ throw new Error("syExchangeRate must be positive")
183
+ }
184
+
185
+ // Lower bound: minimum meaningful YT amount
149
186
  const minPossibleYt = 1
150
187
 
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)
188
+ // Buy-YT uses a flash-swap PT->SY path, so the tradeable size is determined by active
189
+ // tick liquidity, not by the idle PT balance stored on the market account.
154
190
  const marketSyLiquidity = Number(marketState.financials.syBalance)
155
191
 
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
- )
192
+ // Calculate cost at minimum YT to verify function behavior
193
+ let minCost: number
194
+ try {
195
+ const minResult = calculateNetSyCost(marketState, minPossibleYt, syExchangeRate, priceSpotLimit)
196
+ minCost = minResult.netSyCost
197
+ } catch {
198
+ throw new Error("Market cannot support even minimum YT trade")
199
+ }
164
200
 
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
201
+ // If minimum cost already exceeds syIn, we can't do this trade
202
+ if (minCost > syIn) {
203
+ throw new Error(`Trade too small: minimum YT (${minPossibleYt}) costs ${minCost} SY, but only ${syIn} SY available`)
204
+ }
169
205
 
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
206
+ // Find upper bound using exponential search
207
+ // We need to find ytMax where netSyCost(ytMax) > syIn
208
+ let maxPossibleYt = Math.max(minPossibleYt + 1, syToPy(syExchangeRate, syIn)) // Start with naive estimate
209
+ let foundUpperBound = false
210
+ let lastValidYt = minPossibleYt
211
+ let lastValidCost = minCost
174
212
 
175
- const swapResult = simulateSwap(marketState, {
176
- direction: SwapDirection.PtToSy,
177
- amountIn: maxPossibleYt,
178
- priceSpotLimit,
179
- syExchangeRate,
180
- isCurrentFlashSwap: true,
181
- })
213
+ // Exponential search to find upper bound
214
+ const maxAttempts = 40
215
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
216
+ const testYt = Math.max(minPossibleYt + 1, Math.floor(maxPossibleYt))
182
217
 
183
- const syFromPtSale = swapResult.amountOut
184
- const netSyCost = syToStrip - syFromPtSale
218
+ try {
219
+ const result = calculateNetSyCost(marketState, testYt, syExchangeRate, priceSpotLimit)
185
220
 
186
- // If this costs more than syIn, we found our upper bound
187
- if (netSyCost > syIn) {
221
+ if (result.netSyCost > syIn) {
222
+ // Found upper bound
223
+ maxPossibleYt = testYt
188
224
  foundUpperBound = true
189
225
  break
190
226
  }
191
227
 
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)
228
+ // This YT amount is still affordable, save it
229
+ lastValidYt = testYt
230
+ lastValidCost = result.netSyCost
197
231
 
198
- maxPossibleYt *= growthFactor
199
-
200
- // Don't exceed liquidity constraints
201
- if (maxPossibleYt > liquidityBasedMax) {
202
- maxPossibleYt = liquidityBasedMax
203
- foundUpperBound = true
232
+ // Increase estimate using adaptive growth
233
+ // The closer lastValidCost is to syIn, the smaller the growth factor
234
+ const remainingBudget = syIn - lastValidCost
235
+ const growthFactor = Math.max(1.5, Math.min(3, 1 + remainingBudget / Math.max(lastValidCost, 1)))
236
+ maxPossibleYt = Math.ceil(testYt * growthFactor)
237
+ } catch (error) {
238
+ // Swap failed - we've exceeded what the market can handle
239
+ if (lastValidYt > minPossibleYt) {
240
+ // Use binary search between lastValidYt and testYt to find exact upper bound
241
+ let searchLo = lastValidYt
242
+ let searchHi = testYt
243
+
244
+ for (let i = 0; i < 10; i++) {
245
+ const mid = Math.floor((searchLo + searchHi) / 2)
246
+ try {
247
+ const midResult = calculateNetSyCost(marketState, mid, syExchangeRate, priceSpotLimit)
248
+ if (midResult.netSyCost > syIn) {
249
+ searchHi = mid
250
+ foundUpperBound = true
251
+ } else {
252
+ searchLo = mid
253
+ lastValidYt = mid
254
+ lastValidCost = midResult.netSyCost
255
+ }
256
+ } catch {
257
+ searchHi = mid
258
+ }
259
+ }
260
+
261
+ if (foundUpperBound) {
262
+ maxPossibleYt = searchHi
263
+ } else {
264
+ // Couldn't find upper bound - trade exceeds market capacity
265
+ throw new Error(
266
+ `Trade exceeds market capacity: max affordable YT is ~${lastValidYt} (cost: ${lastValidCost}), ` +
267
+ `syIn = ${syIn}`,
268
+ )
269
+ }
204
270
  break
205
271
  }
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)
272
+
273
+ // Re-throw if we haven't found any valid YT amount
274
+ if (error instanceof Error) {
275
+ throw error
216
276
  }
217
- foundUpperBound = true
218
- break
277
+ throw new Error("Failed to find valid YT range")
219
278
  }
220
279
  }
221
280
 
222
281
  if (!foundUpperBound) {
223
- throw new Error(`Could not find upper bound for YT amount. Market may have unusual price dynamics.`)
282
+ throw new Error(
283
+ `Could not find upper bound for YT amount after ${maxAttempts} attempts. ` +
284
+ `Last valid: ${lastValidYt} (cost: ${lastValidCost}), syIn: ${syIn}`,
285
+ )
224
286
  }
225
287
 
226
- // Final safety check: ensure maxPossibleYt > minPossibleYt
288
+ // Sanity check bounds
227
289
  if (maxPossibleYt <= minPossibleYt) {
228
- maxPossibleYt = minPossibleYt * 2
290
+ throw new Error(`Invalid bounds: min=${minPossibleYt}, max=${maxPossibleYt}`)
229
291
  }
230
292
 
231
293
  // 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
- }
294
+ // Epsilon should be small relative to the expected result magnitude
295
+ const adaptiveEpsilon = Math.max(1, syIn * 0.0001)
296
+ const maxIterations = 100 // Bisection converges fast with good bounds
247
297
 
248
298
  const ytOut = bisectSearch2(
249
299
  (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
300
+ const result = calculateNetSyCost(marketState, ytGuess, syExchangeRate, priceSpotLimit)
301
+ return result.netSyCost - syIn
266
302
  },
267
303
  minPossibleYt,
268
304
  maxPossibleYt,
@@ -271,19 +307,37 @@ export function simulateBuyYtWithSyIn(
271
307
  )
272
308
 
273
309
  if (ytOut === null) {
310
+ // This shouldn't happen if bounds are correct
311
+ // Provide detailed debug info
312
+ const minResult = calculateNetSyCost(marketState, minPossibleYt, syExchangeRate, priceSpotLimit)
313
+ let maxResult: { netSyCost: number } | null = null
314
+ try {
315
+ maxResult = calculateNetSyCost(marketState, maxPossibleYt, syExchangeRate, priceSpotLimit)
316
+ } catch {
317
+ // Max might fail
318
+ }
319
+
274
320
  throw new Error(
275
- `Failed to converge on correct YT amount. ` +
276
- `Search range: [${minPossibleYt}, ${maxPossibleYt}], ` +
277
- `Epsilon: ${adaptiveEpsilon}`,
321
+ `Failed to converge: bounds [${minPossibleYt}, ${maxPossibleYt}], ` +
322
+ `f(min)=${minResult.netSyCost - syIn}, f(max)=${maxResult ? maxResult.netSyCost - syIn : "error"}, ` +
323
+ `syIn=${syIn}, epsilon=${adaptiveEpsilon}`,
278
324
  )
279
325
  }
280
326
 
281
- // Now calculate the full result with the found ytOut
282
- return simulateBuyYt(marketState, {
283
- ytOut,
327
+ // Return full result using the found ytOut
328
+ const result = simulateBuyYt(marketState, {
329
+ ytOut: Math.floor(ytOut), // Floor to ensure we don't over-promise
284
330
  syExchangeRate,
285
331
  priceSpotLimit,
286
332
  })
333
+
334
+ if (marketSyLiquidity * syExchangeRate < result.ytOut * 2) {
335
+ throw new Error(
336
+ `Insufficient SY liquidity in the market. Required: ${result.ytOut * 2}, Available: ${marketSyLiquidity}`,
337
+ )
338
+ }
339
+
340
+ return result
287
341
  }
288
342
 
289
343
  export interface SellYtSimulationArgs {
@@ -293,6 +347,13 @@ export interface SellYtSimulationArgs {
293
347
  syExchangeRate: number
294
348
  /** Optional spot price limit (anti-sandwich) */
295
349
  priceSpotLimit?: number
350
+ /**
351
+ * Safety margin in basis points to apply to the expected output.
352
+ * This can be used as an optional conservative buffer on top of the simulated output.
353
+ *
354
+ * Default: 0 (no buffer). Set to a positive value to reduce reported output.
355
+ */
356
+ safetyMarginBps?: number
296
357
  }
297
358
 
298
359
  export interface SellYtSimulationResult {
@@ -328,84 +389,42 @@ export interface SellYtSimulationResult {
328
389
  * @returns Simulation result with net SY received
329
390
  */
330
391
  export function simulateSellYt(marketState: MarketThreeState, args: SellYtSimulationArgs): SellYtSimulationResult {
331
- const { ytIn, syExchangeRate, priceSpotLimit } = args
392
+ const { ytIn, syExchangeRate, priceSpotLimit, safetyMarginBps = 0 } = args
332
393
 
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
- )
394
+ if (ytIn <= 0) {
395
+ throw new Error("ytIn must be positive")
339
396
  }
340
397
 
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
- )
398
+ // Check if there's sufficient PT liquidity
399
+ const marketPtBalance = Number(marketState.financials.ptBalance)
400
+ if (marketPtBalance < ytIn * 2) {
401
+ throw new Error(`Insufficient PT liquidity in the market. Required: ${ytIn * 2}, Available: ${marketPtBalance}`)
360
402
  }
361
403
 
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
404
+ // Merging PT + YT gives back the original SY (floor since you can't get fractional SY)
405
+ const syFromMerge = pyToSy(syExchangeRate, ytIn)
373
406
 
374
- if (safeLowerBound >= safeUpperBound) {
375
- throw new Error(`Invalid bisection bounds for sell YT. Lower: ${safeLowerBound}, Upper: ${safeUpperBound}`)
407
+ if (syFromMerge <= 0) {
408
+ throw new Error(`Trade too small: merging ${ytIn} YT yields 0 SY`)
376
409
  }
377
410
 
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
- }
411
+ // On-chain uses pt_constraint + 2 to account for rounding, so we match that behavior
412
+ const ptNeeded = ytIn + 2
399
413
 
400
- const swapResult = simulateSwap(marketState, {
414
+ // Match on-chain SellYt exactly: TradePtExactOut with pt_target = yt_in + 2 and SY budget = syFromMerge
415
+ const swapResult = simulateSwapExactOut(marketState, {
401
416
  direction: SwapDirection.SyToPt,
402
- amountIn: syToSpend,
417
+ amountOut: ptNeeded,
403
418
  priceSpotLimit,
404
419
  syExchangeRate,
405
420
  isCurrentFlashSwap: true,
421
+ amountInConstraint: syFromMerge,
406
422
  })
407
423
 
408
- const netSyReceived = syFromMerge - swapResult.amountInConsumed
424
+ // Optional conservative output buffer.
425
+ const rawNetSyReceived = syFromMerge - swapResult.amountInConsumed
426
+ const safetyDeduction = safetyMarginBps > 0 ? Math.ceil((rawNetSyReceived * safetyMarginBps) / 10000) : 0
427
+ const netSyReceived = rawNetSyReceived - safetyDeduction
409
428
 
410
429
  return {
411
430
  ytIn,