@exponent-labs/market-three-math 0.9.16 → 0.9.17

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.
@@ -107,6 +107,35 @@ describe("existing position equalization", () => {
107
107
  ])
108
108
  })
109
109
 
110
+ it("rejects overfilled crossing positions instead of producing remove plans", () => {
111
+ const position = makePosition()
112
+ position.lpBalance = 90n
113
+
114
+ expect(() => computeExistingPositionEqualization(makeTicks(), position)).toThrow(
115
+ "CrossingEqualizationInvariantViolated",
116
+ )
117
+ })
118
+
119
+ it("keeps fractional crossing shares and rounds token deltas up", () => {
120
+ const position = makePosition()
121
+ position.shareTrackers[0].lpShare = PRECISE_NUMBER_DENOM / 2n
122
+
123
+ const equalization = computeExistingPositionEqualization(makeTicks(), position)
124
+
125
+ expect(equalization.ptSpent).toBe(3n)
126
+ expect(equalization.sySpent).toBe(4n)
127
+ expect(equalization.plan).toEqual([
128
+ {
129
+ shareIndex: 0,
130
+ tickIdx: 1,
131
+ direction: "add",
132
+ shareDeltaRaw: PRECISE_NUMBER_DENOM / 2n,
133
+ ptDelta: 3n,
134
+ syDelta: 4n,
135
+ },
136
+ ])
137
+ })
138
+
110
139
  it("throws when user budgets cannot cover fixed equalization spend", () => {
111
140
  expect(() =>
112
141
  computeExistingPositionBudgetEffect({
@@ -5,7 +5,7 @@ const PRECISE_NUMBER_DENOM = 1_000_000_000_000n
5
5
  const U64_MAX = (1n << 64n) - 1n
6
6
  const U128_MAX = (1n << 128n) - 1n
7
7
 
8
- export type CrossingEqualizationDirection = "add" | "remove"
8
+ export type CrossingEqualizationDirection = "add"
9
9
 
10
10
  export type CrossingEqualizationPlanStep = {
11
11
  shareIndex: number
@@ -62,15 +62,6 @@ const checkedSubU64 = (left: bigint, right: bigint, label: string) => {
62
62
 
63
63
  const checkedMulU128 = (left: bigint, right: bigint, label: string) => assertU128(left * right, label)
64
64
 
65
- const ceilMulDivU128 = (left: bigint, right: bigint, denominator: bigint, label: string) => {
66
- if (denominator <= 0n) {
67
- throw invariantError(`${label} division by zero`)
68
- }
69
-
70
- const product = checkedMulU128(left, right, `${label} product`)
71
- return assertU128(product + denominator - 1n, `${label} ceil numerator`) / denominator
72
- }
73
-
74
65
  const fastFloorU128 = (rawPreciseNumber: bigint) =>
75
66
  assertU128(rawPreciseNumber, "precise number raw") / PRECISE_NUMBER_DENOM
76
67
 
@@ -80,8 +71,11 @@ const fastMulRatioRaw = (rawPreciseNumber: bigint, numerator: bigint, denominato
80
71
  }
81
72
 
82
73
  const raw = assertU128(rawPreciseNumber, `${label} raw precise number`)
83
- const product = checkedMulU128(raw, numerator, `${label} product`)
84
- return product / denominator
74
+ const q = raw / denominator
75
+ const r = raw % denominator
76
+ const qProduct = checkedMulU128(q, numerator, `${label} quotient product`)
77
+ const rProduct = checkedMulU128(r, numerator, `${label} remainder product`)
78
+ return assertU128(qProduct + rProduct / denominator, `${label} result`)
85
79
  }
86
80
 
87
81
  const getTickByIndex = (ticks: Ticks, tickIdx: number): Tick => {
@@ -138,39 +132,41 @@ const shareIsWithinCrossingRange = (
138
132
  return shareLeftKey >= crossLeftKey && shareRightKey <= crossRightKey
139
133
  }
140
134
 
141
- const computeAddDeltas = (params: {
142
- ownedPt: bigint
143
- ownedSy: bigint
144
- liquidityDelta: bigint
145
- lActual: bigint
146
- }): [bigint, bigint] => {
147
- const ptDelta =
148
- params.ownedPt > 0n
149
- ? assertU64(
150
- ceilMulDivU128(params.ownedPt, params.liquidityDelta, params.lActual, "PT equalization in"),
151
- "PT equalization in",
152
- )
153
- : 0n
154
- const syDelta =
155
- params.ownedSy > 0n
156
- ? assertU64(
157
- ceilMulDivU128(params.ownedSy, params.liquidityDelta, params.lActual, "SY equalization in"),
158
- "SY equalization in",
159
- )
160
- : 0n
135
+ const mulNumberRatioCeilU64 = (amount: bigint, valueRaw: bigint, denominator: bigint, label: string): bigint => {
136
+ if (amount === 0n || valueRaw === 0n) {
137
+ return 0n
138
+ }
139
+ if (denominator <= 0n) {
140
+ throw invariantError(`${label} division by zero`)
141
+ }
161
142
 
162
- return [ptDelta, syDelta]
143
+ const raw = assertU128(valueRaw, `${label} raw precise number`)
144
+ const int = raw / PRECISE_NUMBER_DENOM
145
+ const frac = raw % PRECISE_NUMBER_DENOM
146
+ const intNumerator = checkedMulU128(amount, int, `${label} integer numerator`)
147
+ const quotient = intNumerator / denominator
148
+ const remainder = intNumerator % denominator
149
+ const fractionalNumerator = checkedMulU128(amount, frac, `${label} fractional numerator`)
150
+ const denominatorScaled = checkedMulU128(denominator, PRECISE_NUMBER_DENOM, `${label} scaled denominator`)
151
+ const remainderNumerator = checkedMulU128(remainder, PRECISE_NUMBER_DENOM, `${label} remainder numerator`)
152
+ const ceilNumerator = assertU128(remainderNumerator + fractionalNumerator, `${label} ceil numerator`)
153
+ const roundedRemainder = (ceilNumerator + denominatorScaled - 1n) / denominatorScaled
154
+
155
+ return assertU64(quotient + roundedRemainder, label)
163
156
  }
164
157
 
165
- const computeRemoveDeltas = (params: { shareDeltaRaw: bigint; node: Tick; supply: bigint }): [bigint, bigint] => {
166
- const burnShares = fastFloorU128(params.shareDeltaRaw)
167
- const ptDelta = assertU64(
168
- checkedMulU128(burnShares, params.node.principalPt, "PT equalization out product") / params.supply,
169
- "PT equalization out",
158
+ const computeAddDeltas = (params: { node: Tick; shareDeltaRaw: bigint; supply: bigint }): [bigint, bigint] => {
159
+ const ptDelta = mulNumberRatioCeilU64(
160
+ params.node.principalPt,
161
+ params.shareDeltaRaw,
162
+ params.supply,
163
+ "PT equalization in",
170
164
  )
171
- const syDelta = assertU64(
172
- checkedMulU128(burnShares, params.node.principalSy, "SY equalization out product") / params.supply,
173
- "SY equalization out",
165
+ const syDelta = mulNumberRatioCeilU64(
166
+ params.node.principalSy,
167
+ params.shareDeltaRaw,
168
+ params.supply,
169
+ "SY equalization in",
174
170
  )
175
171
 
176
172
  return [ptDelta, syDelta]
@@ -183,14 +179,8 @@ const summarizePlan = (plan: CrossingEqualizationPlanStep[]): ExistingPositionEq
183
179
  let ptReleased = 0n
184
180
 
185
181
  for (const step of plan) {
186
- if (step.direction === "add") {
187
- sySpent = checkedAddU64(sySpent, step.syDelta, "equalization sy spent")
188
- ptSpent = checkedAddU64(ptSpent, step.ptDelta, "equalization pt spent")
189
- continue
190
- }
191
-
192
- syReleased = checkedAddU64(syReleased, step.syDelta, "equalization sy released")
193
- ptReleased = checkedAddU64(ptReleased, step.ptDelta, "equalization pt released")
182
+ sySpent = checkedAddU64(sySpent, step.syDelta, "equalization sy spent")
183
+ ptSpent = checkedAddU64(ptSpent, step.ptDelta, "equalization pt spent")
194
184
  }
195
185
 
196
186
  return {
@@ -225,15 +215,16 @@ export function computeExistingPositionEqualization(
225
215
  if (lpBalance === lActual) {
226
216
  return summarizePlan([])
227
217
  }
218
+ if (lpBalance < lActual) {
219
+ throw invariantError("active crossing split is overfilled")
220
+ }
228
221
  if (lActual === 0n) {
229
222
  throw invariantError("active crossing split has zero actual liquidity")
230
223
  }
231
224
 
232
- const direction: CrossingEqualizationDirection = lpBalance > lActual ? "add" : "remove"
233
- const liquidityDelta = lpBalance > lActual ? lpBalance - lActual : lActual - lpBalance
225
+ const direction: CrossingEqualizationDirection = "add"
226
+ const liquidityDelta = lpBalance - lActual
234
227
  const plan: CrossingEqualizationPlanStep[] = []
235
- let sawCrossingRangeShare = false
236
- let skippedCrossingShareDueToDust = false
237
228
 
238
229
  for (let shareIndex = 0; shareIndex < position.shareTrackers.length; shareIndex++) {
239
230
  const share = position.shareTrackers[shareIndex]
@@ -241,43 +232,24 @@ export function computeExistingPositionEqualization(
241
232
  continue
242
233
  }
243
234
 
244
- sawCrossingRangeShare = true
245
235
  const node = getTickByIndex(ticks, share.tickIdx)
246
236
  const supply = fastFloorU128(node.principalShareSupply)
247
237
  if (supply === 0n) {
248
- skippedCrossingShareDueToDust = true
249
238
  continue
250
239
  }
251
240
 
252
- const shareFloor = fastFloorU128(share.lpShare)
253
- if (shareFloor === 0n) {
254
- skippedCrossingShareDueToDust = true
241
+ if (share.lpShare === 0n) {
255
242
  continue
256
243
  }
257
244
 
258
- const ownedPt = assertU64(checkedMulU128(shareFloor, node.principalPt, "owned PT product") / supply, "owned PT")
259
- const ownedSy = assertU64(checkedMulU128(shareFloor, node.principalSy, "owned SY product") / supply, "owned SY")
260
245
  const shareDeltaRaw = fastMulRatioRaw(share.lpShare, liquidityDelta, lActual, "share delta")
261
-
262
- let deltas: [bigint, bigint]
263
- if (direction === "add") {
264
- deltas = computeAddDeltas({
265
- ownedPt,
266
- ownedSy,
267
- liquidityDelta,
268
- lActual,
269
- })
270
- } else {
271
- deltas = computeRemoveDeltas({
272
- shareDeltaRaw,
273
- node,
274
- supply,
275
- })
276
- }
277
- const [ptDelta, syDelta] = deltas
246
+ const [ptDelta, syDelta] = computeAddDeltas({
247
+ node,
248
+ shareDeltaRaw,
249
+ supply,
250
+ })
278
251
 
279
252
  if (shareDeltaRaw === 0n && ptDelta === 0n && syDelta === 0n) {
280
- skippedCrossingShareDueToDust = true
281
253
  continue
282
254
  }
283
255
 
@@ -291,9 +263,6 @@ export function computeExistingPositionEqualization(
291
263
  })
292
264
  }
293
265
 
294
- if (plan.length === 0 && sawCrossingRangeShare && skippedCrossingShareDueToDust) {
295
- return summarizePlan([])
296
- }
297
266
  if (plan.length === 0) {
298
267
  throw invariantError("active crossing split has no equalizable crossing-range shares")
299
268
  }
@@ -338,8 +307,8 @@ export function computeExistingPositionBudgetEffect(params: {
338
307
  }
339
308
 
340
309
  /**
341
- * Mirrors `required_user_max_sy/pt`: expands a desired post-equalization budget
342
- * into the user-facing max token amounts that should be passed to the instruction.
310
+ * Expands a desired post-equalization budget into user-facing max token inputs.
311
+ * This is the algebraic inverse of `computeExistingPositionBudgetEffect`.
343
312
  */
344
313
  export function computeRequiredUserMaxForExistingPositionEqualization(params: {
345
314
  ticks: Ticks
package/src/index.ts CHANGED
@@ -48,8 +48,6 @@ export {
48
48
  simulateAddLiquidity,
49
49
  computeLiquidityTargetAndTokenNeeds,
50
50
  computeTokenNeedsWithCrossing,
51
- getCrossingTickStateFromTicks,
52
- scaleCrossingTickInputs,
53
51
  calculateLpOut,
54
52
  estimateBalancedDeposit,
55
53
  calcDepositSyAndPtFromBaseAmount,
@@ -84,8 +82,6 @@ export type {
84
82
  AddLiquidityArgs,
85
83
  AddLiquidityOutcome,
86
84
  LiquidityNeeds,
87
- CrossingTickState,
88
- CrossingScaleParams,
89
85
  MarketThreeState,
90
86
  } from "./types"
91
87
  export { SwapDirection } from "./types"
package/src/swap.ts CHANGED
@@ -309,6 +309,7 @@ export function simulateSwap(marketState: MarketThreeState, args: SwapArgs): Swa
309
309
  amountOut: amountOutNet,
310
310
  lpFeeChargedOutToken: feeLpOut,
311
311
  protocolFeeChargedOutToken: feeProtocolOut,
312
+ boundaryRemainderChargedOutToken: 0,
312
313
  finalSpotPrice: currentPriceSpot,
313
314
  finalTickIndex: currentLeftBoundaryIndex,
314
315
  }
package/src/swapV2.ts CHANGED
@@ -16,6 +16,27 @@ function computeFlashFeeOut(ytValue: number, grossOut: number, lpFeeRate: number
16
16
  return Math.min(grossOut, Math.max(1, getFeeFromAmount(ytValue, lpFeeRate)))
17
17
  }
18
18
 
19
+ function boundaryPrincipalRemainder(
20
+ computedGrossOut: number,
21
+ fullOutgoingPrincipal: number,
22
+ reachesBoundary: boolean,
23
+ ): number {
24
+ if (!reachesBoundary) return 0
25
+ return Math.max(0, fullOutgoingPrincipal - Math.min(computedGrossOut, fullOutgoingPrincipal))
26
+ }
27
+
28
+ function splitFeeWithBoundaryRemainderAsLpFee(
29
+ totalFeeOut: number,
30
+ boundaryRemainder: number,
31
+ protocolFeeBps: number,
32
+ ): { lpFeeOut: number; protocolFeeOut: number } {
33
+ const protocolFeeOut = Math.floor((totalFeeOut * protocolFeeBps) / BASE_POINTS)
34
+ return {
35
+ lpFeeOut: totalFeeOut - protocolFeeOut + boundaryRemainder,
36
+ protocolFeeOut,
37
+ }
38
+ }
39
+
19
40
  /**
20
41
  * Simulate a swap on the CLMM market (V2 - mirrors Rust closely)
21
42
  * This is a pure function that does not mutate the market state
@@ -62,6 +83,7 @@ export function simulateSwap(marketState: MarketThreeState, args: SwapArgs): Swa
62
83
  let amountOutNetU64 = 0
63
84
  let feeLpOutU64 = 0
64
85
  let feeProtocolOutU64 = 0
86
+ let boundaryRemainderChargedOutToken = 0
65
87
  let amountInLeft = args.amountIn
66
88
 
67
89
  if (DEBUG) {
@@ -227,10 +249,16 @@ export function simulateSwap(marketState: MarketThreeState, args: SwapArgs): Swa
227
249
  const anchorUNew = currentPriceSpot + duActual
228
250
  const cEffNew = snapshot.getEffectivePrice(anchorUNew)
229
251
  const syOutGrossF = lTradeF64 * (cEffOld - cEffNew)
252
+ const reachesRightBoundary = Math.abs(anchorURight - anchorUNew) <= eps
230
253
 
231
254
  // Clamp by SY principal
232
255
  let syOutGrossU64 = Math.floor(syOutGrossF)
233
256
  syOutGrossU64 = Math.min(syOutGrossU64, Number(principalSy))
257
+ const syRemainderAtBoundary = boundaryPrincipalRemainder(
258
+ syOutGrossU64,
259
+ Number(principalSy),
260
+ reachesRightBoundary,
261
+ )
234
262
 
235
263
  if (DEBUG) {
236
264
  console.log(` ptInSegment=${ptInSegment}, syOutGrossU64=${syOutGrossU64}`)
@@ -238,7 +266,7 @@ export function simulateSwap(marketState: MarketThreeState, args: SwapArgs): Swa
238
266
 
239
267
  // Fees in token_out (SY)
240
268
  // For flash swaps, fee is based on YT value = (pt - sy × sy_exchange_rate) / sy_exchange_rate (in SY terms)
241
- if (syOutGrossU64 > 0) {
269
+ if (syOutGrossU64 > 0 || syRemainderAtBoundary > 0) {
242
270
  let totalFeeOut: number
243
271
  if (args.isCurrentFlashSwap) {
244
272
  const syOutBase = syOutGrossU64 * args.syExchangeRate
@@ -248,21 +276,25 @@ export function simulateSwap(marketState: MarketThreeState, args: SwapArgs): Swa
248
276
  } else {
249
277
  totalFeeOut = getFeeFromAmount(syOutGrossU64, lpFeeRate)
250
278
  }
251
- const protocolFeeOut = Math.floor((totalFeeOut * protocolFeeBps) / BASE_POINTS)
252
- const lpFeeOut = totalFeeOut - protocolFeeOut
279
+ const { lpFeeOut, protocolFeeOut } = splitFeeWithBoundaryRemainderAsLpFee(
280
+ totalFeeOut,
281
+ syRemainderAtBoundary,
282
+ protocolFeeBps,
283
+ )
253
284
  const syOutNet = syOutGrossU64 - totalFeeOut
254
285
 
255
286
  // Accumulate to user
256
287
  amountOutNetU64 += syOutNet
257
288
  feeLpOutU64 += lpFeeOut
258
289
  feeProtocolOutU64 += protocolFeeOut
290
+ boundaryRemainderChargedOutToken += syRemainderAtBoundary
259
291
 
260
292
  // Mirror on-chain principal mutation for this interval:
261
293
  // +PT input (gross), -SY output (gross)
262
294
  ticksWrapper.setPrincipals(
263
295
  currentLeftBoundaryKey,
264
296
  principalPt + BigInt(ptInSegment),
265
- principalSy - BigInt(syOutGrossU64),
297
+ reachesRightBoundary ? 0n : principalSy - BigInt(syOutGrossU64),
266
298
  )
267
299
 
268
300
  // Consume input and advance state
@@ -320,6 +352,7 @@ export function simulateSwap(marketState: MarketThreeState, args: SwapArgs): Swa
320
352
  // New effective price and spot after consuming ΔC
321
353
  const cEffNew = cEffOld + deltaCActual
322
354
  const spotPriceNew = snapshot.spotPriceFromEffectivePrice(cEffNew)
355
+ const reachesLeftBoundary = Math.abs(cEffNew - cEffLeft) <= eps || Math.abs(spotPriceNew - anchorULeft) <= eps
323
356
 
324
357
  // Token flows
325
358
  const syInSegmentF = lTradeF64 * (cEffNew - cEffOld)
@@ -329,6 +362,11 @@ export function simulateSwap(marketState: MarketThreeState, args: SwapArgs): Swa
329
362
  // Clamp gross PT by available principal
330
363
  let ptOutGrossU64 = Math.floor(ptOutGrossF)
331
364
  ptOutGrossU64 = Math.min(ptOutGrossU64, Number(principalPt))
365
+ const ptRemainderAtBoundary = boundaryPrincipalRemainder(
366
+ ptOutGrossU64,
367
+ Number(principalPt),
368
+ reachesLeftBoundary,
369
+ )
332
370
  // ceil for input to protect pool
333
371
  const syInSegmentU64 = Math.ceil(syInSegmentF)
334
372
 
@@ -338,7 +376,7 @@ export function simulateSwap(marketState: MarketThreeState, args: SwapArgs): Swa
338
376
  }
339
377
 
340
378
  // Match on-chain behavior: if no PT can be taken from this interval, cross and continue.
341
- if (ptOutGrossU64 === 0) {
379
+ if (ptOutGrossU64 === 0 && ptRemainderAtBoundary === 0) {
342
380
  const crossed = crossOneBoundary(ticksWrapper, SwapDirection.SyToPt, currentLeftBoundaryKey, {
343
381
  currentLeftBoundaryKey,
344
382
  currentPriceSpot,
@@ -353,7 +391,7 @@ export function simulateSwap(marketState: MarketThreeState, args: SwapArgs): Swa
353
391
  continue
354
392
  }
355
393
 
356
- if (ptOutGrossU64 > 0) {
394
+ if (ptOutGrossU64 > 0 || ptRemainderAtBoundary > 0) {
357
395
  // Fees in token_out (PT)
358
396
  // For flash swaps, fee is based on YT value = pt - sy × sy_exchange_rate
359
397
  let totalFeeOut: number
@@ -364,20 +402,24 @@ export function simulateSwap(marketState: MarketThreeState, args: SwapArgs): Swa
364
402
  } else {
365
403
  totalFeeOut = getFeeFromAmount(ptOutGrossU64, lpFeeRate)
366
404
  }
367
- const protocolFeeOut = Math.floor((totalFeeOut * protocolFeeBps) / BASE_POINTS)
368
- const lpFeeOut = totalFeeOut - protocolFeeOut
405
+ const { lpFeeOut, protocolFeeOut } = splitFeeWithBoundaryRemainderAsLpFee(
406
+ totalFeeOut,
407
+ ptRemainderAtBoundary,
408
+ protocolFeeBps,
409
+ )
369
410
  const ptOutNet = ptOutGrossU64 - totalFeeOut
370
411
 
371
412
  // Accumulate to user
372
413
  amountOutNetU64 += ptOutNet
373
414
  feeLpOutU64 += lpFeeOut
374
415
  feeProtocolOutU64 += protocolFeeOut
416
+ boundaryRemainderChargedOutToken += ptRemainderAtBoundary
375
417
 
376
418
  // Mirror on-chain principal mutation for this interval:
377
419
  // +SY input (gross), -PT output (gross)
378
420
  ticksWrapper.setPrincipals(
379
421
  currentLeftBoundaryKey,
380
- principalPt - BigInt(ptOutGrossU64),
422
+ reachesLeftBoundary ? 0n : principalPt - BigInt(ptOutGrossU64),
381
423
  principalSy + BigInt(syInSegmentU64),
382
424
  )
383
425
  }
@@ -414,6 +456,7 @@ export function simulateSwap(marketState: MarketThreeState, args: SwapArgs): Swa
414
456
  amountOut: amountOutNetU64,
415
457
  lpFeeChargedOutToken: feeLpOutU64,
416
458
  protocolFeeChargedOutToken: feeProtocolOutU64,
459
+ boundaryRemainderChargedOutToken,
417
460
  finalSpotPrice: currentPriceSpot,
418
461
  finalTickKey: currentLeftBoundaryKey, // This is now the key, not array index
419
462
  postMarketState,
@@ -457,6 +500,7 @@ export function simulateSwapExactOut(marketState: MarketThreeState, args: SwapEx
457
500
  amountOut: 0,
458
501
  lpFeeChargedOutToken: 0,
459
502
  protocolFeeChargedOutToken: 0,
503
+ boundaryRemainderChargedOutToken: 0,
460
504
  finalSpotPrice: currentPriceSpot,
461
505
  finalTickKey: currentLeftBoundaryKey,
462
506
  postMarketState: marketState,
@@ -468,6 +512,7 @@ export function simulateSwapExactOut(marketState: MarketThreeState, args: SwapEx
468
512
  let amountInConsumed = 0
469
513
  let feeLpOutU64 = 0
470
514
  let feeProtocolOutU64 = 0
515
+ let boundaryRemainderChargedOutToken = 0
471
516
 
472
517
  let iterations = 0
473
518
  const MAX_ITERATIONS = 1000
@@ -498,8 +543,15 @@ export function simulateSwapExactOut(marketState: MarketThreeState, args: SwapEx
498
543
  const cEffOld = snapshot.getEffectivePrice(currentPriceSpot)
499
544
  const cEffLeft = snapshot.getEffectivePrice(anchorULeft)
500
545
  const deltaCToLeftBoundary = cEffLeft - cEffOld
546
+ const { principalPt, principalSy } = ticksWrapper.getPrincipals(currentLeftBoundaryKey)
501
547
 
502
548
  if (!Number.isFinite(deltaCToLeftBoundary) || deltaCToLeftBoundary <= eps) {
549
+ if (Number.isFinite(deltaCToLeftBoundary) && deltaCToLeftBoundary <= eps && principalPt > 0n) {
550
+ const ptRemainderAtBoundary = Number(principalPt)
551
+ feeLpOutU64 += ptRemainderAtBoundary
552
+ boundaryRemainderChargedOutToken += ptRemainderAtBoundary
553
+ ticksWrapper.setPrincipals(currentLeftBoundaryKey, 0n, principalSy)
554
+ }
503
555
  const crossed = crossOneBoundary(ticksWrapper, SwapDirection.SyToPt, currentLeftBoundaryKey, {
504
556
  currentLeftBoundaryKey,
505
557
  currentPriceSpot,
@@ -514,8 +566,6 @@ export function simulateSwapExactOut(marketState: MarketThreeState, args: SwapEx
514
566
  continue
515
567
  }
516
568
 
517
- const { principalPt, principalSy } = ticksWrapper.getPrincipals(currentLeftBoundaryKey)
518
-
519
569
  const duToLeft = currentPriceSpot - anchorULeft
520
570
  if (!Number.isFinite(duToLeft) || deltaCToLeftBoundary <= eps) {
521
571
  const crossed = crossOneBoundary(ticksWrapper, SwapDirection.SyToPt, currentLeftBoundaryKey, {
@@ -585,6 +635,12 @@ export function simulateSwapExactOut(marketState: MarketThreeState, args: SwapEx
585
635
  ptOutGrossBoundaryU64 = Math.min(ptOutGrossBoundaryU64, Number(principalPt))
586
636
 
587
637
  if (ptOutGrossBoundaryU64 === 0) {
638
+ const ptRemainderAtBoundary = boundaryPrincipalRemainder(0, Number(principalPt), true)
639
+ feeLpOutU64 += ptRemainderAtBoundary
640
+ boundaryRemainderChargedOutToken += ptRemainderAtBoundary
641
+ if (ptRemainderAtBoundary > 0) {
642
+ ticksWrapper.setPrincipals(currentLeftBoundaryKey, 0n, principalSy)
643
+ }
588
644
  const crossed = crossOneBoundary(ticksWrapper, SwapDirection.SyToPt, currentLeftBoundaryKey, {
589
645
  currentLeftBoundaryKey,
590
646
  currentPriceSpot,
@@ -633,17 +689,26 @@ export function simulateSwapExactOut(marketState: MarketThreeState, args: SwapEx
633
689
 
634
690
  amountInConsumed += syInBoundary
635
691
 
636
- if (boundarySegment.totalFeeOut > 0) {
637
- const protocolFeeOut = Math.floor((boundarySegment.totalFeeOut * protocolFeeBps) / BASE_POINTS)
638
- const lpFeeOut = boundarySegment.totalFeeOut - protocolFeeOut
692
+ const ptRemainderAtBoundary = boundaryPrincipalRemainder(
693
+ boundarySegment.ptOutGrossActual,
694
+ Number(principalPt),
695
+ true,
696
+ )
697
+ if (boundarySegment.totalFeeOut > 0 || ptRemainderAtBoundary > 0) {
698
+ const { lpFeeOut, protocolFeeOut } = splitFeeWithBoundaryRemainderAsLpFee(
699
+ boundarySegment.totalFeeOut,
700
+ ptRemainderAtBoundary,
701
+ protocolFeeBps,
702
+ )
639
703
  feeLpOutU64 += lpFeeOut
640
704
  feeProtocolOutU64 += protocolFeeOut
705
+ boundaryRemainderChargedOutToken += ptRemainderAtBoundary
641
706
  }
642
707
 
643
708
  if (syInBoundary > 0 || boundarySegment.ptOutGrossActual > 0) {
644
709
  ticksWrapper.setPrincipals(
645
710
  currentLeftBoundaryKey,
646
- principalPt - BigInt(boundarySegment.ptOutGrossActual),
711
+ 0n,
647
712
  principalSy + BigInt(syInBoundary),
648
713
  )
649
714
  }
@@ -740,14 +805,26 @@ export function simulateSwapExactOut(marketState: MarketThreeState, args: SwapEx
740
805
  amountOutAccum += netActual
741
806
  amountInConsumed += syInSegmentU64
742
807
 
743
- const protocolFeeOut = Math.floor((segment.totalFeeOut * protocolFeeBps) / BASE_POINTS)
744
- const lpFeeOut = segment.totalFeeOut - protocolFeeOut
808
+ const reachesLeftBoundary =
809
+ Math.abs(snapshot.getEffectivePrice(segment.spotPriceNew) - cEffLeft) <= eps ||
810
+ segment.ptOutGrossActual === ptOutGrossBoundaryU64
811
+ const ptRemainderAtBoundary = boundaryPrincipalRemainder(
812
+ segment.ptOutGrossActual,
813
+ Number(principalPt),
814
+ reachesLeftBoundary,
815
+ )
816
+ const { lpFeeOut, protocolFeeOut } = splitFeeWithBoundaryRemainderAsLpFee(
817
+ segment.totalFeeOut,
818
+ ptRemainderAtBoundary,
819
+ protocolFeeBps,
820
+ )
745
821
  feeLpOutU64 += lpFeeOut
746
822
  feeProtocolOutU64 += protocolFeeOut
823
+ boundaryRemainderChargedOutToken += ptRemainderAtBoundary
747
824
 
748
825
  ticksWrapper.setPrincipals(
749
826
  currentLeftBoundaryKey,
750
- principalPt - BigInt(segment.ptOutGrossActual),
827
+ reachesLeftBoundary ? 0n : principalPt - BigInt(segment.ptOutGrossActual),
751
828
  principalSy + BigInt(syInSegmentU64),
752
829
  )
753
830
 
@@ -786,6 +863,7 @@ export function simulateSwapExactOut(marketState: MarketThreeState, args: SwapEx
786
863
  amountOut: amountOutAccum,
787
864
  lpFeeChargedOutToken: feeLpOutU64,
788
865
  protocolFeeChargedOutToken: feeProtocolOutU64,
866
+ boundaryRemainderChargedOutToken,
789
867
  finalSpotPrice: currentPriceSpot,
790
868
  finalTickKey: currentLeftBoundaryKey,
791
869
  postMarketState,
package/src/types.ts CHANGED
@@ -53,6 +53,8 @@ export interface SwapOutcome {
53
53
  lpFeeChargedOutToken: number
54
54
  /** Protocol fee charged in out token */
55
55
  protocolFeeChargedOutToken: number
56
+ /** Boundary principal remainder distributed to LPs in out token */
57
+ boundaryRemainderChargedOutToken: number
56
58
  /** Final spot price after swap */
57
59
  finalSpotPrice: number
58
60
  /** Final tick index after swap */
@@ -68,6 +70,8 @@ export interface SwapOutcomeV2 {
68
70
  lpFeeChargedOutToken: number
69
71
  /** Protocol fee charged in out token */
70
72
  protocolFeeChargedOutToken: number
73
+ /** Boundary principal remainder distributed to LPs in out token */
74
+ boundaryRemainderChargedOutToken: number
71
75
  /** Final spot price after swap */
72
76
  finalSpotPrice: number
73
77
  /** Final tick key after swap */
@@ -109,27 +113,4 @@ export interface LiquidityNeeds {
109
113
  priceSplitForNeed: number
110
114
  /** Tick index of the split point */
111
115
  priceSplitTickIdx: number
112
- /** Original max SY before CLMM trimming (for crossing tick scaling) */
113
- originalMaxSy: number
114
- /** Original max PT before CLMM trimming (for crossing tick scaling) */
115
- originalMaxPt: number
116
- /** PT distribution extent: current_spot - lower_price */
117
- duLeftTotal: number
118
- /** SY distribution extent: eff_price_current - eff_price_upper */
119
- deltaCRightTotal: number
120
- }
121
-
122
- /** State of the crossing (current) tick for proportional distribution */
123
- export interface CrossingTickState {
124
- principalPt: number
125
- principalSy: number
126
- principalShareSupply: number
127
- }
128
-
129
- /** Parameters for scaling crossing tick inputs from original max values */
130
- export interface CrossingScaleParams {
131
- originalMaxSy: number
132
- originalMaxPt: number
133
- duLeftTotal: number
134
- deltaCRightTotal: number
135
116
  }
package/src/utilsV2.ts CHANGED
@@ -273,9 +273,7 @@ export class TicksWrapper {
273
273
  apyBasePoints: key,
274
274
  principalShareSupply: 0n,
275
275
  farms: templateTick ? templateTick.farms.map(() => ({ lastSeenIndex: 0 })) : [],
276
- emissions: templateTick
277
- ? templateTick.emissions.map(() => ({ lastSeenIndex: 0, lastPositionIndex: 0 }))
278
- : [],
276
+ emissions: templateTick ? templateTick.emissions.map(() => ({ lastSeenIndex: 0, lastPositionIndex: 0 })) : [],
279
277
  lastSplitEpoch: 0n,
280
278
  splitParentEpoch: 0n,
281
279
  frozenLiquidity: 0n,
@@ -287,6 +285,10 @@ export class TicksWrapper {
287
285
  this.sortedKeys.push(key)
288
286
  this.sortedKeys.sort((a, b) => a - b)
289
287
 
288
+ if (newTick.impliedRate <= this.currentSpotPrice && key > this.currentTickKey) {
289
+ this.currentTickKey = key
290
+ }
291
+
290
292
  this.splitPrincipalsAfterInsert(key, snap)
291
293
  }
292
294
 
@@ -301,7 +303,8 @@ export class TicksWrapper {
301
303
  const currentTickIdx = this.baseTicksTree.findIndex((tick) => tick.apyBasePoints === currentTickKey)
302
304
  const fallbackTickKey = this.findTickKeyBySpotPrice(currentSpotPrice)
303
305
  const fallbackTickIdx = this.baseTicksTree.findIndex((tick) => tick.apyBasePoints === fallbackTickKey)
304
- const resolvedCurrentTick = currentTickIdx >= 0 ? currentTickIdx + 1 : fallbackTickIdx >= 0 ? fallbackTickIdx + 1 : 0
306
+ const resolvedCurrentTick =
307
+ currentTickIdx >= 0 ? currentTickIdx + 1 : fallbackTickIdx >= 0 ? fallbackTickIdx + 1 : 0
305
308
 
306
309
  return {
307
310
  currentTick: resolvedCurrentTick,