@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
@@ -2,10 +2,63 @@
2
2
  * CLMM Add Liquidity simulation
3
3
  * Ported from exponent_clmm/src/state/market_three/helpers/add_liquidity.rs
4
4
  */
5
- import { AddLiquidityArgs, AddLiquidityOutcome, LiquidityNeeds, MarketThreeState } from "./types"
6
- import { EffSnap, normalizedTimeRemaining } from "./utils"
5
+ import { simulateSwap, simulateSwapExactOut } from "./swapV2"
6
+ import {
7
+ AddLiquidityArgs,
8
+ AddLiquidityOutcome,
9
+ CrossingScaleParams,
10
+ CrossingTickState,
11
+ LiquidityNeeds,
12
+ MarketThreeState,
13
+ SwapDirection,
14
+ } from "./types"
15
+ import { EffSnap, normalizedTimeRemaining, TicksWrapper } from "./utilsV2"
16
+ import { findTickByKey, getSuccessorTickKey } from "./utils"
17
+ import { Ticks } from "@exponent-labs/exponent-fetcher"
7
18
 
8
19
  const TICK_KEY_BASE_POINTS = 1_000_000
20
+ // Keep this in sync with exponent_clmm's add_liquidity helper so off-chain
21
+ // quotes match the on-chain classic deposit path as closely as possible.
22
+ const GAP_TOKEN_NEEDS = 5
23
+ const SWAP_EXACT_OUT_SY_HEADROOM = 10
24
+
25
+ const EMPTY_CROSSING_TICK_STATE: CrossingTickState = {
26
+ principalPt: 0,
27
+ principalSy: 0,
28
+ principalShareSupply: 0,
29
+ }
30
+
31
+ /**
32
+ * Get crossing tick state and price boundaries from Ticks.
33
+ * Matches wrapper_provide_liquidity.rs: current node's spot_price, principal_pt/sy/supply, successor's spot_price.
34
+ */
35
+ export function getCrossingTickStateFromTicks(ticks: Ticks): {
36
+ crossingTickState: CrossingTickState
37
+ crossingTickPriceLeft: number
38
+ crossingTickPriceRight: number
39
+ } {
40
+ const currentTickNode = ticks.ticksTree[ticks.currentTick - 1]
41
+ if (!currentTickNode || ticks.currentTick <= 0) {
42
+ return {
43
+ crossingTickState: EMPTY_CROSSING_TICK_STATE,
44
+ crossingTickPriceLeft: 0,
45
+ crossingTickPriceRight: 0,
46
+ }
47
+ }
48
+ const crossingTickPriceLeft = currentTickNode.impliedRate
49
+ const successorKey = getSuccessorTickKey(ticks, currentTickNode.apyBasePoints)
50
+ const successorTick = successorKey != null ? findTickByKey(ticks, successorKey)?.tick : null
51
+ const crossingTickPriceRight = successorTick?.impliedRate ?? Number.POSITIVE_INFINITY
52
+ return {
53
+ crossingTickState: {
54
+ principalPt: Number(currentTickNode.principalPt),
55
+ principalSy: Number(currentTickNode.principalSy),
56
+ principalShareSupply: Number(currentTickNode.principalShareSupply),
57
+ },
58
+ crossingTickPriceLeft,
59
+ crossingTickPriceRight,
60
+ }
61
+ }
9
62
 
10
63
  /**
11
64
  * Compute liquidity target and token needs based on position range and budgets
@@ -41,6 +94,11 @@ export function computeLiquidityTargetAndTokenNeeds(
41
94
  ptNeeded: 0,
42
95
  priceSplitForNeed: lowerPrice,
43
96
  priceSplitTickIdx: lowerTickIdx,
97
+ // No crossing possible when below range
98
+ originalMaxSy: maxSy,
99
+ originalMaxPt: maxPt,
100
+ duLeftTotal: 0,
101
+ deltaCRightTotal: deltaCTotal,
44
102
  }
45
103
  } else if (spotPriceCurrent >= upperPrice) {
46
104
  // Above range: PT only (NOT SY!)
@@ -55,9 +113,14 @@ export function computeLiquidityTargetAndTokenNeeds(
55
113
  ptNeeded: Math.floor(ptNeed),
56
114
  priceSplitForNeed: upperPrice,
57
115
  priceSplitTickIdx: upperTickIdx,
116
+ // No crossing possible when above range
117
+ originalMaxSy: maxSy,
118
+ originalMaxPt: maxPt,
119
+ duLeftTotal: duTotal,
120
+ deltaCRightTotal: 0,
58
121
  }
59
122
  } else {
60
- // Inside range: both sides
123
+ // Inside range: both sides - crossing tick possible
61
124
  const duLeft = Math.max(spotPriceCurrent - lowerPrice, epsilonClamp)
62
125
  const liquidityFromPt = maxPt / duLeft
63
126
 
@@ -75,8 +138,529 @@ export function computeLiquidityTargetAndTokenNeeds(
75
138
  ptNeeded: Math.floor(ptNeed),
76
139
  priceSplitForNeed: spotPriceCurrent,
77
140
  priceSplitTickIdx: currentIndex,
141
+ // Store for crossing tick scaling
142
+ originalMaxSy: maxSy,
143
+ originalMaxPt: maxPt,
144
+ duLeftTotal: duLeft,
145
+ deltaCRightTotal: deltaCRight,
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Scale crossing tick token inputs from original max values to prevent double trimming.
152
+ *
153
+ * When adding liquidity that spans the current price (crossing tick), tokens get trimmed twice:
154
+ * 1. First by CLMM formula in computeLiquidityTargetAndTokenNeeds
155
+ * 2. Then by AMM formula in simulateAddLiquidityProportional
156
+ *
157
+ * This function calculates the segment's proportion of the total distribution and scales
158
+ * the original max values accordingly, ensuring maximum token utilization.
159
+ *
160
+ * @param scaleParams - Original max values and CLMM distribution extents
161
+ * @param duPtPart - PT segment length (spot price delta for this segment)
162
+ * @param syPerL1 - SY per unit liquidity for this segment
163
+ * @param clmmPtDelta - PT amount calculated from CLMM formula
164
+ * @param clmmSyDelta - SY amount calculated from CLMM formula
165
+ * @returns Tuple of [scaledPtIn, scaledSyIn] - maximum of CLMM-calculated and scaled original values
166
+ */
167
+ export function scaleCrossingTickInputs(
168
+ scaleParams: CrossingScaleParams,
169
+ duPtPart: number,
170
+ syPerL1: number,
171
+ clmmPtDelta: number,
172
+ clmmSyDelta: number,
173
+ ): [number, number] {
174
+ const { duLeftTotal, deltaCRightTotal, originalMaxPt, originalMaxSy } = scaleParams
175
+
176
+ if (duLeftTotal > 0 && deltaCRightTotal > 0) {
177
+ // Calculate segment's proportion of total distribution
178
+ const ptSegmentRatio = duPtPart / duLeftTotal
179
+ const sySegmentRatio = syPerL1 / deltaCRightTotal
180
+
181
+ // Scale original max values by segment proportion
182
+ const scaledPt = Math.ceil(originalMaxPt * ptSegmentRatio)
183
+ const scaledSy = Math.ceil(originalMaxSy * sySegmentRatio)
184
+
185
+ // Use max of CLMM-calculated value and scaled original value
186
+ // This ensures we don't lose tokens due to double trimming
187
+ return [Math.max(clmmPtDelta, scaledPt), Math.max(clmmSyDelta, scaledSy)]
188
+ }
189
+
190
+ // Fallback to original logic if no crossing scale params
191
+ return [clmmPtDelta, clmmSyDelta]
192
+ }
193
+
194
+ /**
195
+ * Simulate the proportional add_liquidity logic
196
+ * Returns [usedPt, usedSy] based on existing tick proportions
197
+ */
198
+ function simulateAddLiquidityProportional(
199
+ intentPt: number,
200
+ intentSy: number,
201
+ marketTotalPt: number,
202
+ marketTotalSy: number,
203
+ marketTotalLp: number,
204
+ ): [number, number] {
205
+ if (marketTotalLp === 0 || marketTotalPt === 0 || marketTotalSy === 0) {
206
+ // Empty tick - use all intended amounts
207
+ return [intentPt, intentSy]
208
+ }
209
+
210
+ const lpFromPt = (marketTotalLp * intentPt) / marketTotalPt
211
+ const lpFromSy = (marketTotalLp * intentSy) / marketTotalSy
212
+
213
+ if (lpFromPt < lpFromSy) {
214
+ // PT is the limiting factor
215
+ const lpTokensOut = lpFromPt
216
+ const usedPt = intentPt
217
+ const usedSy = Math.ceil((marketTotalSy * lpTokensOut) / marketTotalLp)
218
+ return [usedPt, usedSy]
219
+ } else {
220
+ // SY is the limiting factor
221
+ const lpTokensOut = lpFromSy
222
+ const usedSy = intentSy
223
+ const usedPt = Math.ceil((marketTotalPt * lpTokensOut) / marketTotalLp)
224
+ return [usedPt, usedSy]
225
+ }
226
+ }
227
+
228
+ function toU64BigInt(value: number): bigint {
229
+ return BigInt(Math.max(0, Math.floor(value)))
230
+ }
231
+
232
+ function ceilDivBigInt(numerator: bigint, denominator: bigint): bigint {
233
+ if (denominator <= 0n) {
234
+ return 0n
235
+ }
236
+ return numerator <= 0n ? 0n : (numerator + denominator - 1n) / denominator
237
+ }
238
+
239
+ function floorSqrtBigInt(value: bigint): bigint {
240
+ if (value <= 1n) {
241
+ return value
242
+ }
243
+ let x0 = value
244
+ let x1 = (x0 + 1n) >> 1n
245
+ while (x1 < x0) {
246
+ x0 = x1
247
+ x1 = (x1 + value / x1) >> 1n
248
+ }
249
+ return x0
250
+ }
251
+
252
+ type VirtualTickState = {
253
+ principalPt: bigint
254
+ principalSy: bigint
255
+ principalShareSupply: bigint
256
+ }
257
+
258
+ function getVirtualTickState(
259
+ key: number,
260
+ ticksWrapper: TicksWrapper,
261
+ virtualStates: Map<number, VirtualTickState>,
262
+ ): VirtualTickState {
263
+ const cached = virtualStates.get(key)
264
+ if (cached) {
265
+ return cached
266
+ }
267
+
268
+ const { principalPt, principalSy } = ticksWrapper.getPrincipals(key)
269
+ const tick = ticksWrapper.getTickByKey(key)
270
+ const state: VirtualTickState = {
271
+ principalPt,
272
+ principalSy,
273
+ principalShareSupply: tick ? BigInt(tick.principalShareSupply) : 0n,
274
+ }
275
+ virtualStates.set(key, state)
276
+ return state
277
+ }
278
+
279
+ function simulateMintSharesForTickPrestateCalcUsed(params: {
280
+ key: number
281
+ dptIn: bigint
282
+ dsyIn: bigint
283
+ ticksWrapper: TicksWrapper
284
+ virtualStates: Map<number, VirtualTickState>
285
+ }): { minted: bigint; usedPt: bigint; usedSy: bigint } | null {
286
+ const { key, dptIn, dsyIn, ticksWrapper, virtualStates } = params
287
+ const state = getVirtualTickState(key, ticksWrapper, virtualStates)
288
+
289
+ const ptBefore = state.principalPt
290
+ const syBefore = state.principalSy
291
+ const supply = state.principalShareSupply
292
+
293
+ let minted = 0n
294
+ let usedPt = 0n
295
+ let usedSy = 0n
296
+
297
+ const hasPt = dptIn > 0n
298
+ const hasSy = dsyIn > 0n
299
+
300
+ if (hasPt && hasSy) {
301
+ if (supply === 0n) {
302
+ minted = floorSqrtBigInt(dptIn * dsyIn)
303
+ usedPt = dptIn
304
+ usedSy = dsyIn
305
+ } else {
306
+ if (ptBefore === 0n || syBefore === 0n) {
307
+ return null
308
+ }
309
+
310
+ const lpFromPt = (supply * dptIn) / ptBefore
311
+ const lpFromSy = (supply * dsyIn) / syBefore
312
+ const usePtSide = lpFromPt < lpFromSy
313
+ const lpTokensOut = usePtSide ? lpFromPt : lpFromSy
314
+
315
+ minted = lpTokensOut
316
+ if (minted === 0n) {
317
+ return null
318
+ }
319
+
320
+ if (usePtSide) {
321
+ usedPt = dptIn
322
+ usedSy = ceilDivBigInt(syBefore * lpTokensOut, supply)
323
+ } else {
324
+ usedSy = dsyIn
325
+ usedPt = ceilDivBigInt(ptBefore * lpTokensOut, supply)
326
+ }
327
+ }
328
+ } else if (hasPt && !hasSy) {
329
+ usedSy = 0n
330
+ if (supply === 0n && ptBefore === 0n) {
331
+ minted = dptIn
332
+ usedPt = dptIn
333
+ } else {
334
+ if (supply === 0n || ptBefore === 0n) {
335
+ return null
336
+ }
337
+ minted = (supply * dptIn) / ptBefore
338
+ if (minted === 0n) {
339
+ return null
340
+ }
341
+ usedPt = dptIn
342
+ }
343
+ } else if (!hasPt && hasSy) {
344
+ usedPt = 0n
345
+ if (supply === 0n && syBefore === 0n) {
346
+ minted = dsyIn
347
+ usedSy = dsyIn
348
+ } else {
349
+ if (supply === 0n || syBefore === 0n) {
350
+ return null
351
+ }
352
+ minted = (supply * dsyIn) / syBefore
353
+ if (minted === 0n) {
354
+ return null
355
+ }
356
+ usedSy = dsyIn
357
+ }
358
+ } else {
359
+ return { minted: 0n, usedPt: 0n, usedSy: 0n }
360
+ }
361
+
362
+ if (minted > 0n) {
363
+ state.principalShareSupply += minted
364
+ }
365
+ if (usedPt > 0n || usedSy > 0n) {
366
+ state.principalPt += usedPt
367
+ state.principalSy += usedSy
368
+ ticksWrapper.setPrincipals(key, state.principalPt, state.principalSy)
369
+ }
370
+
371
+ return { minted, usedPt, usedSy }
372
+ }
373
+
374
+ function simulateAccruePrincipalForDeposit(params: {
375
+ ticks: Ticks
376
+ snap: EffSnap
377
+ lowerPrice: number
378
+ upperPrice: number
379
+ priceSplitForNeed: number
380
+ splitTickKey: number
381
+ lowerTickKey: number
382
+ upperTickKey: number
383
+ liquidityTarget: number
384
+ originalMaxSy: number
385
+ originalMaxPt: number
386
+ }): { sySpent: number; ptSpent: number } {
387
+ const {
388
+ ticks,
389
+ snap,
390
+ lowerPrice,
391
+ upperPrice,
392
+ priceSplitForNeed,
393
+ splitTickKey,
394
+ lowerTickKey,
395
+ upperTickKey,
396
+ liquidityTarget,
397
+ originalMaxSy,
398
+ originalMaxPt,
399
+ } = params
400
+
401
+ const ticksWrapper = new TicksWrapper(ticks)
402
+ ticksWrapper.upsertBoundaryTick(lowerTickKey, snap)
403
+ ticksWrapper.upsertBoundaryTick(upperTickKey, snap)
404
+ const virtualStates = new Map<number, VirtualTickState>()
405
+
406
+ let totalSySpend = 0n
407
+ let totalPtSpend = 0n
408
+ let pendingCrossPt: { leftKey: number; rightKey: number; ptDelta: bigint; duPart: number } | null = null
409
+ let pendingCrossSy: { leftKey: number; rightKey: number; syDelta: bigint; syPerL: number } | null = null
410
+
411
+ const resolveTraversalStartKey = (candidateKey: number): number => {
412
+ if (ticksWrapper.getTickByKey(candidateKey)) {
413
+ return candidateKey
414
+ }
415
+ const predecessor = ticksWrapper.predecessorKey(candidateKey)
416
+ return predecessor ?? candidateKey
417
+ }
418
+
419
+ const visitIntervals = (startKey: number, priceStart: number, priceEnd: number, visitor: (params: {
420
+ leftKey: number
421
+ rightKey: number
422
+ leftPrice: number
423
+ rightPrice: number
424
+ }) => void) => {
425
+ if (!(priceStart < priceEnd)) {
426
+ return
427
+ }
428
+
429
+ let currentKey = startKey
430
+ while (true) {
431
+ const rightKey = ticksWrapper.successorKey(currentKey)
432
+ if (rightKey == null) {
433
+ break
434
+ }
435
+
436
+ const leftPrice = ticksWrapper.getSpotPrice(currentKey)
437
+ const rightPrice = ticksWrapper.getSpotPrice(rightKey)
438
+ visitor({ leftKey: currentKey, rightKey, leftPrice, rightPrice })
439
+
440
+ if (rightPrice >= priceEnd) {
441
+ break
442
+ }
443
+ currentKey = rightKey
78
444
  }
79
445
  }
446
+
447
+ const ptSliceStart = lowerPrice
448
+ const ptSliceEnd = Math.min(priceSplitForNeed, upperPrice)
449
+ const ptStartKey = resolveTraversalStartKey(lowerTickKey)
450
+ visitIntervals(ptStartKey, ptSliceStart, ptSliceEnd, ({ leftKey, rightKey, leftPrice, rightPrice }) => {
451
+ const segmentStart = Math.max(leftPrice, ptSliceStart)
452
+ const segmentEnd = Math.min(rightPrice, ptSliceEnd)
453
+ const segmentLength = segmentEnd - segmentStart
454
+ if (!(segmentLength > 0)) {
455
+ return
456
+ }
457
+
458
+ const isCrossing = leftPrice < priceSplitForNeed && rightPrice > priceSplitForNeed
459
+ const principalPtDelta = toU64BigInt(Math.ceil(liquidityTarget * segmentLength))
460
+ if (principalPtDelta <= 0n) {
461
+ return
462
+ }
463
+
464
+ if (isCrossing) {
465
+ pendingCrossPt = {
466
+ leftKey,
467
+ rightKey,
468
+ ptDelta: principalPtDelta,
469
+ duPart: segmentLength,
470
+ }
471
+ return
472
+ }
473
+
474
+ const minted = simulateMintSharesForTickPrestateCalcUsed({
475
+ key: leftKey,
476
+ dptIn: principalPtDelta,
477
+ dsyIn: 0n,
478
+ ticksWrapper,
479
+ virtualStates,
480
+ })
481
+ if (!minted) {
482
+ throw new Error("Deposit too small to mint shares in PT segment")
483
+ }
484
+
485
+ totalPtSpend += minted.usedPt
486
+ totalSySpend += minted.usedSy
487
+ })
488
+
489
+ const sySliceStart = Math.max(priceSplitForNeed, lowerPrice)
490
+ const sySliceEnd = upperPrice
491
+ const syStartCandidate = splitTickKey >= lowerTickKey ? splitTickKey : lowerTickKey
492
+ const syStartKey = resolveTraversalStartKey(syStartCandidate)
493
+
494
+ visitIntervals(syStartKey, sySliceStart, sySliceEnd, ({ leftKey, rightKey, leftPrice, rightPrice }) => {
495
+ const segmentStart = Math.max(leftPrice, sySliceStart)
496
+ const segmentEnd = Math.min(rightPrice, sySliceEnd)
497
+ if (!(segmentEnd > segmentStart)) {
498
+ return
499
+ }
500
+
501
+ const syPerL = snap.getEffectivePrice(segmentStart) - snap.getEffectivePrice(segmentEnd)
502
+ const principalSyDelta = toU64BigInt(Math.ceil(liquidityTarget * syPerL))
503
+ if (principalSyDelta <= 0n) {
504
+ return
505
+ }
506
+
507
+ const isCrossing = leftPrice < priceSplitForNeed && rightPrice > priceSplitForNeed
508
+ if (isCrossing) {
509
+ pendingCrossSy = {
510
+ leftKey,
511
+ rightKey,
512
+ syDelta: principalSyDelta,
513
+ syPerL,
514
+ }
515
+ return
516
+ }
517
+
518
+ const minted = simulateMintSharesForTickPrestateCalcUsed({
519
+ key: leftKey,
520
+ dptIn: 0n,
521
+ dsyIn: principalSyDelta,
522
+ ticksWrapper,
523
+ virtualStates,
524
+ })
525
+ if (!minted) {
526
+ throw new Error("Deposit too small to mint shares in SY segment")
527
+ }
528
+
529
+ totalPtSpend += minted.usedPt
530
+ totalSySpend += minted.usedSy
531
+ })
532
+
533
+ if (pendingCrossPt && pendingCrossSy) {
534
+ const ptLeft = BigInt(Math.max(0, Math.floor(originalMaxPt) - Number(totalPtSpend) - GAP_TOKEN_NEEDS))
535
+ const syLeft = BigInt(Math.max(0, Math.floor(originalMaxSy) - Number(totalSySpend) - GAP_TOKEN_NEEDS))
536
+ const scaledPtIn = pendingCrossPt.ptDelta > ptLeft ? pendingCrossPt.ptDelta : ptLeft
537
+ const scaledSyIn = pendingCrossSy.syDelta > syLeft ? pendingCrossSy.syDelta : syLeft
538
+
539
+ const minted = simulateMintSharesForTickPrestateCalcUsed({
540
+ key: pendingCrossPt.leftKey,
541
+ dptIn: scaledPtIn,
542
+ dsyIn: scaledSyIn,
543
+ ticksWrapper,
544
+ virtualStates,
545
+ })
546
+ if (!minted) {
547
+ throw new Error("Deposit too small to mint shares in crossing segment")
548
+ }
549
+
550
+ totalPtSpend += minted.usedPt
551
+ totalSySpend += minted.usedSy
552
+ }
553
+
554
+ const sySpent = Number(totalSySpend)
555
+ const ptSpent = Number(totalPtSpend)
556
+ if (!Number.isSafeInteger(sySpent) || !Number.isSafeInteger(ptSpent)) {
557
+ throw new Error("Token spend exceeds JS safe integer range")
558
+ }
559
+
560
+ return { sySpent, ptSpent }
561
+ }
562
+
563
+ /**
564
+ * Compute token needs with crossing tick adjustment
565
+ * This matches the Rust compute_token_needs_with_crossing function
566
+ */
567
+ export function computeTokenNeedsWithCrossing(
568
+ snap: EffSnap,
569
+ spotPriceCurrent: number,
570
+ priceEffLower: number,
571
+ priceEffUpper: number,
572
+ lowerPrice: number,
573
+ upperPrice: number,
574
+ maxSy: number,
575
+ maxPt: number,
576
+ epsilonClamp: number,
577
+ crossingTickState: CrossingTickState,
578
+ crossingTickPriceLeft: number,
579
+ crossingTickPriceRight: number,
580
+ ): [number, number] {
581
+ // Below range: SY only
582
+ if (spotPriceCurrent <= lowerPrice) {
583
+ const deltaCTotal = priceEffLower - priceEffUpper
584
+ const liquidityFromSy = maxSy / deltaCTotal
585
+ const syNeed = liquidityFromSy * deltaCTotal
586
+ return [Math.ceil(syNeed), 0]
587
+ }
588
+
589
+ // Above range: PT only
590
+ if (spotPriceCurrent >= upperPrice) {
591
+ const duTotal = upperPrice - lowerPrice
592
+ const liquidityFromPt = maxPt / duTotal
593
+ const ptNeed = liquidityFromPt * duTotal
594
+ return [0, Math.ceil(ptNeed)]
595
+ }
596
+
597
+ // Inside range: both sides
598
+ const duLeft = Math.max(spotPriceCurrent - lowerPrice, epsilonClamp)
599
+ const priceEffCurrent = snap.getEffectivePrice(spotPriceCurrent)
600
+ const deltaCRight = Math.max(priceEffCurrent - priceEffUpper, epsilonClamp)
601
+
602
+ const liquidityFromPt = maxPt / duLeft
603
+ const liquidityFromSy = maxSy / deltaCRight
604
+
605
+ const liquidityTarget = Math.min(liquidityFromPt, liquidityFromSy)
606
+ let ptNeed = liquidityTarget * duLeft
607
+ let syNeed = liquidityTarget * deltaCRight
608
+
609
+ // Apply crossing tick adjustment if the tick has existing liquidity
610
+ // Match Rust: !crossing_tick_state.is_empty() where is_empty = (principal_share_supply == 0)
611
+ const isCrossingTickActive =
612
+ crossingTickState.principalShareSupply > 0 &&
613
+ crossingTickPriceLeft < spotPriceCurrent &&
614
+ crossingTickPriceRight > spotPriceCurrent
615
+
616
+ if (isCrossingTickActive) {
617
+ // Calculate PT and SY portions that would go into the crossing tick
618
+ // PT portion: from crossing_tick_price_left to spot_price_current (match Rust)
619
+ const crossingPtSegment = spotPriceCurrent - Math.max(crossingTickPriceLeft, lowerPrice)
620
+ const crossingPtIntended = Math.ceil(liquidityTarget * crossingPtSegment)
621
+
622
+ // SY portion: from spotPriceCurrent to crossingTickPriceRight
623
+ const crossingSySegmentStart = Math.max(spotPriceCurrent, crossingTickPriceLeft)
624
+ const crossingSySegmentEnd = Math.min(crossingTickPriceRight, upperPrice)
625
+ const syPerL = snap.getEffectivePrice(crossingSySegmentStart) - snap.getEffectivePrice(crossingSySegmentEnd)
626
+ const crossingSyIntended = Math.ceil(liquidityTarget * Math.max(syPerL, 0))
627
+
628
+ if (crossingPtIntended > 0 && crossingSyIntended > 0) {
629
+ // Tokens already allocated to non-crossing segments before crossing processing.
630
+ const ptOutsideCrossing = Math.max(ptNeed - crossingPtIntended, 0)
631
+ const syOutsideCrossing = Math.max(syNeed - crossingSyIntended, 0)
632
+
633
+ // Apply scaling from original max values to prevent double trimming
634
+ const [scaledPtIn, scaledSyIn] =
635
+ duLeft > 0 && deltaCRight > 0
636
+ ? (() => {
637
+ const totalPtSpend = Math.ceil(ptOutsideCrossing)
638
+ const totalSySpend = Math.ceil(syOutsideCrossing)
639
+ const ptLeft = Math.max(maxPt - totalPtSpend - GAP_TOKEN_NEEDS, 0)
640
+ const syLeft = Math.max(maxSy - totalSySpend - GAP_TOKEN_NEEDS, 0)
641
+
642
+ // Use max of CLMM-calculated value and scaled original value
643
+ return [Math.max(crossingPtIntended, ptLeft), Math.max(crossingSyIntended, syLeft)]
644
+ })()
645
+ : [crossingPtIntended, crossingSyIntended]
646
+
647
+ // Simulate add_liquidity proportional logic with scaled inputs
648
+ const [usedPt, usedSy] = simulateAddLiquidityProportional(
649
+ scaledPtIn,
650
+ scaledSyIn,
651
+ crossingTickState.principalPt,
652
+ crossingTickState.principalSy,
653
+ crossingTickState.principalShareSupply,
654
+ )
655
+
656
+ // Adjust needs based on what would actually be used
657
+ ptNeed = ptOutsideCrossing + usedPt
658
+
659
+ syNeed = syOutsideCrossing + usedSy
660
+ }
661
+ }
662
+
663
+ return [Math.ceil(syNeed), Math.ceil(ptNeed)]
80
664
  }
81
665
 
82
666
  /**
@@ -101,6 +685,9 @@ export function simulateAddLiquidity(marketState: MarketThreeState, args: AddLiq
101
685
  const priceEffLower = snap.getEffectivePrice(lowerPrice)
102
686
  const priceEffUpper = snap.getEffectivePrice(upperPrice)
103
687
 
688
+ const maxSyForNeeds = Math.max(0, Math.floor(args.maxSy) - GAP_TOKEN_NEEDS)
689
+ const maxPtForNeeds = Math.max(0, Math.floor(args.maxPt) - GAP_TOKEN_NEEDS)
690
+
104
691
  // Calculate liquidity needs
105
692
  const liquidityNeeds = computeLiquidityTargetAndTokenNeeds(
106
693
  snap,
@@ -112,14 +699,33 @@ export function simulateAddLiquidity(marketState: MarketThreeState, args: AddLiq
112
699
  args.lowerTick,
113
700
  args.upperTick,
114
701
  ticks.currentTick,
115
- args.maxSy,
116
- args.maxPt,
702
+ maxSyForNeeds,
703
+ maxPtForNeeds,
117
704
  configurationOptions.epsilonClamp,
118
705
  )
119
706
 
707
+ const currentTickNode = ticks.ticksTree[ticks.currentTick - 1]
708
+ const currentTickKey =
709
+ currentTickNode && currentTickNode.apyBasePoints > 0 ? currentTickNode.apyBasePoints : args.lowerTick
710
+ const splitTickKey =
711
+ currentSpot <= lowerPrice ? args.lowerTick : currentSpot >= upperPrice ? args.upperTick : currentTickKey
712
+ const { sySpent: syNeededWithCrossing, ptSpent: ptNeededWithCrossing } = simulateAccruePrincipalForDeposit({
713
+ ticks,
714
+ snap,
715
+ lowerPrice,
716
+ upperPrice,
717
+ priceSplitForNeed: liquidityNeeds.priceSplitForNeed,
718
+ splitTickKey,
719
+ lowerTickKey: args.lowerTick,
720
+ upperTickKey: args.upperTick,
721
+ liquidityTarget: liquidityNeeds.liquidityTarget,
722
+ originalMaxSy: liquidityNeeds.originalMaxSy,
723
+ originalMaxPt: liquidityNeeds.originalMaxPt,
724
+ })
725
+
120
726
  // Enforce budgets
121
- const sySpent = liquidityNeeds.syNeeded
122
- const ptSpent = liquidityNeeds.ptNeeded
727
+ const sySpent = syNeededWithCrossing
728
+ const ptSpent = ptNeededWithCrossing
123
729
 
124
730
  if (sySpent > args.maxSy) {
125
731
  throw new Error(`Insufficient SY budget: need ${sySpent}, have ${args.maxSy}`)
@@ -198,8 +804,26 @@ export function calcDepositSyAndPtFromBaseAmount(params: {
198
804
  expirationTs: number
199
805
  currentSpotPrice: number
200
806
  syExchangeRate: number
807
+ /** Optional crossing tick state for accurate ratio prediction.
808
+ * When provided, the prediction accounts for the existing PT/SY proportions
809
+ * in the active tick, matching the on-chain behaviour more closely. */
810
+ crossingTickState?: CrossingTickState
811
+ crossingTickPriceLeft?: number
812
+ crossingTickPriceRight?: number
813
+ epsilonClamp?: number
201
814
  }) {
202
- const { expirationTs, currentSpotPrice, syExchangeRate, lowerPrice, upperPrice, baseTokenAmount } = params
815
+ const {
816
+ expirationTs,
817
+ currentSpotPrice,
818
+ syExchangeRate,
819
+ lowerPrice,
820
+ upperPrice,
821
+ baseTokenAmount,
822
+ crossingTickState = EMPTY_CROSSING_TICK_STATE,
823
+ crossingTickPriceLeft = 0,
824
+ crossingTickPriceRight = 0,
825
+ epsilonClamp = 1e-18,
826
+ } = params
203
827
 
204
828
  if (baseTokenAmount <= 0 || syExchangeRate <= 0) {
205
829
  return {
@@ -215,23 +839,25 @@ export function calcDepositSyAndPtFromBaseAmount(params: {
215
839
  const priceEffUpper = effSnap.getEffectivePrice(upperPrice)
216
840
 
217
841
  // We mirror the on-chain logic in `wrapper_provide_liquidity`:
218
- // 1. Use a large mock amount for both SY and PT to infer the *ratio* of SY/PT
219
- // the market "wants" for this price range (compute_token_needs on-chain).
842
+ // 1. Use compute_token_needs_with_crossing to infer the ratio of SY/PT
843
+ // the market "wants" for this price range.
844
+ // When crossing tick state is provided, this accounts for the existing
845
+ // PT/SY proportions in the active tick, significantly improving accuracy.
220
846
  // 2. Use that ratio plus the current SY exchange rate to decide how much of the
221
- // minted SY should be stripped into PT to keep the user-close to pool proportions.
222
- const { syNeeded: syMock, ptNeeded: ptMock } = computeLiquidityTargetAndTokenNeeds(
847
+ // minted SY should be stripped into PT (calc_strip_amount).
848
+ const [syMock, ptMock] = computeTokenNeedsWithCrossing(
223
849
  effSnap,
224
850
  currentSpotPrice,
225
851
  priceEffLower,
226
852
  priceEffUpper,
227
853
  lowerPrice,
228
854
  upperPrice,
229
- 0,
230
- 0,
231
- 0,
232
855
  1e9,
233
856
  1e9,
234
- 1e-18,
857
+ epsilonClamp,
858
+ crossingTickState,
859
+ crossingTickPriceLeft,
860
+ crossingTickPriceRight,
235
861
  )
236
862
 
237
863
  // Total SY the user would get by minting SY from base off-chain
@@ -267,7 +893,9 @@ export function calcDepositSyAndPtFromBaseAmount(params: {
267
893
  * Solution: B = (totalAmountSy * marketPtLiq) / (marketPtLiq + marketSyLiq * curSyRate)
268
894
  */
269
895
  function calcStripAmount(totalAmountSy: number, curSyRate: number, marketPtLiq: number, marketSyLiq: number): number {
270
- const toStrip = (totalAmountSy * marketPtLiq) / (curSyRate * marketSyLiq + marketPtLiq)
896
+ const denominator = curSyRate * marketSyLiq + marketPtLiq
897
+ if (denominator <= 0) return 0
898
+ const toStrip = (totalAmountSy * marketPtLiq) / denominator
271
899
  return Math.ceil(toStrip)
272
900
  }
273
901
 
@@ -324,42 +952,43 @@ export function simulateWrapperProvideLiquidity(
324
952
  const priceEffLower = snap.getEffectivePrice(lowerPrice)
325
953
  const priceEffUpper = snap.getEffectivePrice(upperPrice)
326
954
 
327
- // Step 3: Calculate "mock" token needs using MOCK_AMOUNT (1e9)
328
- // This gives us the ratio of SY:PT needed for this tick range
329
- const MOCK_AMOUNT = 1e9
330
- const mockNeeds = computeLiquidityTargetAndTokenNeeds(
955
+ // Step 3: Get crossing tick state (matches wrapper_provide_liquidity.rs)
956
+ const { crossingTickState, crossingTickPriceLeft, crossingTickPriceRight } =
957
+ getCrossingTickStateFromTicks(ticks)
958
+
959
+ // Step 4: Calculate mock token needs using compute_token_needs_with_crossing
960
+ // max_sy = syAmount, max_pt = syAmount * syExchangeRate (as on-chain)
961
+ const maxPt = syAmount * syExchangeRate
962
+ const [syMock, ptMock] = computeTokenNeedsWithCrossing(
331
963
  snap,
332
964
  currentSpot,
333
965
  priceEffLower,
334
966
  priceEffUpper,
335
967
  lowerPrice,
336
968
  upperPrice,
337
- lowerTick,
338
- upperTick,
339
- ticks.currentTick,
340
- MOCK_AMOUNT,
341
- MOCK_AMOUNT,
969
+ syAmount,
970
+ maxPt,
342
971
  configurationOptions.epsilonClamp,
972
+ crossingTickState,
973
+ crossingTickPriceLeft,
974
+ crossingTickPriceRight,
343
975
  )
344
976
 
345
- // Step 4: Calculate how much SY to strip
346
- // Uses the mock amounts to determine the ratio
347
- const syToStrip = calcStripAmount(syAmount, syExchangeRate, mockNeeds.ptNeeded, mockNeeds.syNeeded)
348
-
349
- console.log("[simulateWrapperProvideLiquidity] Strip calculation:", {
350
- syToStrip,
351
- formula: `(${syAmount} * ${mockNeeds.ptNeeded}) / (${syExchangeRate} * ${mockNeeds.syNeeded} + ${mockNeeds.ptNeeded})`,
352
- })
977
+ // Step 5: Calculate how much SY to strip (calc_strip_amount on-chain)
978
+ // Cap syToStrip to syAmount to prevent negative remainder from ceil rounding
979
+ const syToStripRaw = calcStripAmount(syAmount, syExchangeRate, ptMock, syMock)
980
+ const syToStrip = Math.min(syToStripRaw, syAmount)
353
981
 
354
- // Step 5: Calculate PT and YT from stripping
982
+ // Step 6: Calculate PT and YT from stripping
355
983
  // When you strip SY, you get PT = SY * syExchangeRate and YT = SY * syExchangeRate
356
984
  const ptFromStrip = syToStrip * syExchangeRate
357
985
  const ytOut = ptFromStrip // YT amount equals PT amount from strip
358
986
 
359
- // Step 6: Calculate remaining SY after strip
360
- const syRemainder = syAmount - syToStrip
987
+ // Step 7: Calculate remaining SY after strip
988
+ // Use Math.max to ensure non-negative (safety net for floating point edge cases)
989
+ const syRemainder = Math.max(0, syAmount - syToStrip)
361
990
 
362
- // Step 7: Simulate deposit liquidity with remaining SY and PT
991
+ // Step 8: Simulate deposit liquidity with remaining SY and PT
363
992
  const depositResult = simulateAddLiquidity(marketState, {
364
993
  lowerTick,
365
994
  upperTick,
@@ -382,3 +1011,348 @@ export function simulateWrapperProvideLiquidity(
382
1011
  return null
383
1012
  }
384
1013
  }
1014
+
1015
+ /**
1016
+ * Simulate swap & supply operation (ixProvideLiquidityBase)
1017
+ * 1. Mints SY from base asset
1018
+ * 2. Swaps some SY for PT on the market (instead of stripping)
1019
+ * 3. Deposits remaining SY + bought PT into liquidity position
1020
+ *
1021
+ * This approach has lower slippage than minting because it uses market liquidity.
1022
+ *
1023
+ * @param marketState - Current market state
1024
+ * @param amountBase - Amount of base tokens (in lamports)
1025
+ * @param lowerTick - Lower tick key (APY in basis points)
1026
+ * @param upperTick - Upper tick key (APY in basis points)
1027
+ * @param syExchangeRate - SY exchange rate
1028
+ * @returns Simulation result with LP out, PT to buy, SY constraint, etc.
1029
+ */
1030
+ export function simulateSwapAndSupply(
1031
+ marketState: MarketThreeState,
1032
+ amountBase: number,
1033
+ lowerTick: number,
1034
+ upperTick: number,
1035
+ syExchangeRate: number,
1036
+ ): {
1037
+ lpOut: number
1038
+ ptToBuy: number
1039
+ syConstraint: number
1040
+ syForSwap: number
1041
+ syRemainder: number
1042
+ ptFromSwap: number
1043
+ syDeposited: number
1044
+ ptDeposited: number
1045
+ } | null {
1046
+ try {
1047
+ // Wrapper provide-liquidity-base debits base as:
1048
+ // base_needed = ceil(total_sy_spent * sy_exchange_rate)
1049
+ // So the strict SY budget for a user-provided base input is floor(base / rate).
1050
+ const syBudget = convertBaseToSyBudget(amountBase)
1051
+
1052
+ if (syBudget <= 0) {
1053
+ return {
1054
+ lpOut: 0,
1055
+ ptToBuy: 0,
1056
+ syConstraint: 0,
1057
+ syForSwap: 0,
1058
+ syRemainder: 0,
1059
+ ptFromSwap: 0,
1060
+ syDeposited: 0,
1061
+ ptDeposited: 0,
1062
+ }
1063
+ }
1064
+
1065
+ const MOCK_AMOUNT = 1e9
1066
+ const mockNeeds = simulateAddLiquidity(marketState, {
1067
+ lowerTick,
1068
+ upperTick,
1069
+ maxSy: MOCK_AMOUNT,
1070
+ maxPt: MOCK_AMOUNT,
1071
+ syExchangeRate,
1072
+ })
1073
+ const searchBudget = Math.max(0, syBudget - SWAP_EXACT_OUT_SY_HEADROOM)
1074
+
1075
+ // Wrapper flow still executes a flash swap even when external_pt_to_buy is 0
1076
+ // (effective exact-out target becomes 2 due +2 safety margin).
1077
+ let externalPtToBuy = 0
1078
+ if (syBudget > 0 && mockNeeds.ptSpent > 0) {
1079
+ if (mockNeeds.sySpent > 0) {
1080
+ // Normal case: position range includes current spot price
1081
+ // Calculate optimal split between swap and deposit
1082
+ const denominator = syExchangeRate * mockNeeds.sySpent + mockNeeds.ptSpent
1083
+ const syToSwapGuess = denominator > 0 ? Math.ceil((syBudget * mockNeeds.ptSpent) / denominator) : 0
1084
+
1085
+ if (syToSwapGuess > 0) {
1086
+ const guessSwap = simulateSwap(marketState, {
1087
+ direction: SwapDirection.SyToPt,
1088
+ amountIn: syToSwapGuess,
1089
+ syExchangeRate,
1090
+ isCurrentFlashSwap: true,
1091
+ })
1092
+ externalPtToBuy = Math.max(0, Math.floor(guessSwap.amountOut))
1093
+ }
1094
+ } else {
1095
+ // Below-range case: position only needs PT (sySpent = 0)
1096
+ // Swap all SY to PT
1097
+ const guessSwap = simulateSwap(marketState, {
1098
+ direction: SwapDirection.SyToPt,
1099
+ amountIn: syBudget,
1100
+ syExchangeRate,
1101
+ isCurrentFlashSwap: true,
1102
+ })
1103
+ externalPtToBuy = Math.max(0, Math.floor(guessSwap.amountOut))
1104
+ }
1105
+ }
1106
+
1107
+ const candidateCache = new Map<number, SwapAndSupplyCandidate | null>()
1108
+ const evaluateCandidate = (ptConstraint: number) => {
1109
+ const key = Math.max(0, Math.floor(ptConstraint))
1110
+ const cached = candidateCache.get(key)
1111
+ if (cached !== undefined) {
1112
+ return cached
1113
+ }
1114
+ const candidate = simulateSwapAndSupplyForPtConstraint(
1115
+ marketState,
1116
+ lowerTick,
1117
+ upperTick,
1118
+ syBudget,
1119
+ key,
1120
+ syExchangeRate,
1121
+ )
1122
+ candidateCache.set(key, candidate)
1123
+ return candidate
1124
+ }
1125
+ const ptCap = 1_000_000_000
1126
+ let bestUnderBudget: SwapAndSupplyCandidate | null = null
1127
+ let leastOverspend: SwapAndSupplyCandidate | null = null
1128
+ const considerCandidate = (candidate: SwapAndSupplyCandidate | null) => {
1129
+ if (!candidate) {
1130
+ return
1131
+ }
1132
+ if (candidate.totalSySpent <= searchBudget) {
1133
+ if (
1134
+ !bestUnderBudget ||
1135
+ candidate.totalSySpent > bestUnderBudget.totalSySpent ||
1136
+ (candidate.totalSySpent === bestUnderBudget.totalSySpent && candidate.lpOut > bestUnderBudget.lpOut)
1137
+ ) {
1138
+ bestUnderBudget = candidate
1139
+ }
1140
+ return
1141
+ }
1142
+
1143
+ if (
1144
+ !leastOverspend ||
1145
+ candidate.totalSySpent < leastOverspend.totalSySpent ||
1146
+ (candidate.totalSySpent === leastOverspend.totalSySpent && candidate.lpOut > leastOverspend.lpOut)
1147
+ ) {
1148
+ leastOverspend = candidate
1149
+ }
1150
+ }
1151
+
1152
+ // Always evaluate from zero to establish a valid lower bound.
1153
+ considerCandidate(evaluateCandidate(0))
1154
+
1155
+ let lowPt = 0
1156
+ let highPt = Math.max(1, externalPtToBuy)
1157
+ let highCandidate = evaluateCandidate(highPt)
1158
+ considerCandidate(highCandidate)
1159
+ if (highCandidate && highCandidate.totalSySpent <= searchBudget) {
1160
+ lowPt = highPt
1161
+ }
1162
+
1163
+ // Expand upward until we hit over-budget/null region so binary search can maximize usage.
1164
+ for (let i = 0; i < 18 && highPt < ptCap; i++) {
1165
+ if (!highCandidate || highCandidate.totalSySpent > searchBudget) {
1166
+ break
1167
+ }
1168
+ const nextHigh = Math.min(ptCap, highPt * 2)
1169
+ if (nextHigh === highPt) {
1170
+ break
1171
+ }
1172
+ highPt = nextHigh
1173
+ highCandidate = evaluateCandidate(highPt)
1174
+ considerCandidate(highCandidate)
1175
+ if (highCandidate && highCandidate.totalSySpent <= searchBudget) {
1176
+ lowPt = highPt
1177
+ }
1178
+ }
1179
+
1180
+ // Binary search the feasibility boundary.
1181
+ let searchLow = Math.max(0, lowPt + 1)
1182
+ let searchHigh = highPt
1183
+ while (searchLow <= searchHigh) {
1184
+ const mid = Math.floor((searchLow + searchHigh) / 2)
1185
+ const candidate = evaluateCandidate(mid)
1186
+ considerCandidate(candidate)
1187
+
1188
+ if (candidate && candidate.totalSySpent <= searchBudget) {
1189
+ searchLow = mid + 1
1190
+ } else {
1191
+ searchHigh = mid - 1
1192
+ }
1193
+ }
1194
+
1195
+ // Local sweep around the boundary to handle small non-monotonic steps.
1196
+ const localRadius = 16
1197
+ const localStart = Math.max(0, searchHigh - localRadius)
1198
+ const localEnd = Math.min(ptCap, searchHigh + localRadius)
1199
+ for (let pt = localStart; pt <= localEnd; pt++) {
1200
+ considerCandidate(evaluateCandidate(pt))
1201
+ }
1202
+
1203
+ const selected = bestUnderBudget ?? leastOverspend
1204
+ if (!selected) {
1205
+ throw new Error("Unable to simulate swap & supply for initial PT constraint")
1206
+ }
1207
+ if (process.env.DEBUG_SWAP_SUPPLY === "1") {
1208
+ console.log("[simulateSwapAndSupply]", {
1209
+ syBudget,
1210
+ searchBudget,
1211
+ mockNeeds,
1212
+ externalPtToBuy,
1213
+ selected,
1214
+ })
1215
+ }
1216
+
1217
+ return {
1218
+ lpOut: selected.lpOut,
1219
+ ptToBuy: selected.ptConstraint,
1220
+ syConstraint: syBudget,
1221
+ syForSwap: selected.tradeSySpent,
1222
+ syRemainder: selected.syRemainderAfterSwap,
1223
+ ptFromSwap: selected.tradePtOut,
1224
+ syDeposited: selected.depositSySpent,
1225
+ ptDeposited: selected.depositPtSpent,
1226
+ }
1227
+ } catch (error) {
1228
+ console.error("[simulateSwapAndSupply] Error:", error)
1229
+ return null
1230
+ }
1231
+ }
1232
+
1233
+ type SwapAndSupplyCandidate = {
1234
+ ptConstraint: number
1235
+ tradeSySpent: number
1236
+ tradePtOut: number
1237
+ depositSySpent: number
1238
+ depositPtSpent: number
1239
+ lpOut: number
1240
+ totalSySpent: number
1241
+ syRemainderAfterSwap: number
1242
+ }
1243
+
1244
+ function simulateSwapAndSupplyForPtConstraint(
1245
+ marketState: MarketThreeState,
1246
+ lowerTick: number,
1247
+ upperTick: number,
1248
+ syBudget: number,
1249
+ ptConstraint: number,
1250
+ syExchangeRate: number,
1251
+ ): SwapAndSupplyCandidate | null {
1252
+ let swapResult: {
1253
+ sySpent: number
1254
+ ptOut: number
1255
+ postMarketState?: MarketThreeState
1256
+ }
1257
+
1258
+ try {
1259
+ swapResult = simulateBuyPtExactOutWrapper(
1260
+ marketState,
1261
+ syBudget,
1262
+ ptConstraint,
1263
+ syExchangeRate,
1264
+ )
1265
+ } catch {
1266
+ // Budget insufficient for this PT constraint
1267
+ return null
1268
+ }
1269
+
1270
+ const depositState = swapResult.postMarketState ?? marketState
1271
+ const syAvailableForDeposit = Math.max(0, syBudget - swapResult.sySpent)
1272
+ const depositResult = simulateAddLiquidity(depositState, {
1273
+ lowerTick,
1274
+ upperTick,
1275
+ maxSy: syBudget,
1276
+ maxPt: swapResult.ptOut,
1277
+ syExchangeRate,
1278
+ })
1279
+
1280
+ // Swap & supply must actually add liquidity.
1281
+ // Discard candidates that end up as swap-only (deltaL == 0).
1282
+ if (depositResult.deltaL <= 0) {
1283
+ return null
1284
+ }
1285
+
1286
+ const ptRemainder = Math.max(0, swapResult.ptOut - depositResult.ptSpent)
1287
+ if (ptRemainder > 1_000) {
1288
+ return null
1289
+ }
1290
+
1291
+ const totalSySpent = swapResult.sySpent + depositResult.sySpent
1292
+
1293
+ return {
1294
+ ptConstraint,
1295
+ tradeSySpent: swapResult.sySpent,
1296
+ tradePtOut: swapResult.ptOut,
1297
+ depositSySpent: depositResult.sySpent,
1298
+ depositPtSpent: depositResult.ptSpent,
1299
+ lpOut: depositResult.deltaL,
1300
+ totalSySpent,
1301
+ syRemainderAfterSwap: Math.max(0, syBudget - swapResult.sySpent),
1302
+ }
1303
+ }
1304
+
1305
+ function convertBaseToSyBudget(amountBase: number): number {
1306
+ const baseAmount = Math.max(0, Math.floor(amountBase))
1307
+ return baseAmount
1308
+ }
1309
+
1310
+ function simulateBuyPtExactOutWrapper(
1311
+ marketState: MarketThreeState,
1312
+ syBudget: number,
1313
+ ptConstraint: number,
1314
+ syExchangeRate: number,
1315
+ ): {
1316
+ sySpent: number
1317
+ ptOut: number
1318
+ postMarketState?: MarketThreeState
1319
+ } {
1320
+ const maxSyBudget = Math.max(0, Math.floor(syBudget))
1321
+ const effectivePtOutTarget = Math.max(0, Math.floor(ptConstraint)) + 2
1322
+
1323
+ if (maxSyBudget === 0) {
1324
+ return {
1325
+ sySpent: 0,
1326
+ ptOut: 0,
1327
+ postMarketState: marketState,
1328
+ }
1329
+ }
1330
+
1331
+ const tryExactOut = (targetPtOut: number) => {
1332
+ return simulateSwapExactOut(marketState, {
1333
+ direction: SwapDirection.SyToPt,
1334
+ amountOut: targetPtOut,
1335
+ syExchangeRate,
1336
+ isCurrentFlashSwap: true,
1337
+ amountInConstraint: maxSyBudget,
1338
+ })
1339
+ }
1340
+
1341
+ // WrapperProvideLiquidityBase always executes exact-out with `pt_constraint + 2`.
1342
+ // Do not fallback to lower exact-out targets here, otherwise client simulation can
1343
+ // accept candidates that deterministically fail on-chain with InsufficientBudgetSY.
1344
+ let bestQuote: ReturnType<typeof tryExactOut>
1345
+ try {
1346
+ bestQuote = tryExactOut(effectivePtOutTarget)
1347
+ } catch {
1348
+ throw new Error(
1349
+ `Insufficient SY budget for wrapper exact-out simulation (budget=${maxSyBudget}, pt_constraint=${ptConstraint})`,
1350
+ )
1351
+ }
1352
+
1353
+ return {
1354
+ sySpent: bestQuote.amountInConsumed,
1355
+ ptOut: bestQuote.amountOut,
1356
+ postMarketState: bestQuote.postMarketState,
1357
+ }
1358
+ }