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

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.
@@ -0,0 +1,189 @@
1
+ import { PublicKey } from "@solana/web3.js"
2
+
3
+ import type { LpPositionCLMM, MarketThree, Tick, Ticks } from "@exponent-labs/exponent-fetcher"
4
+
5
+ import { getPtAndSyOnWithdrawLiquidity } from "./withdrawLiquidity"
6
+
7
+ const PRECISE_NUMBER_DENOM = 1_000_000_000_000n
8
+ const SENTINEL_TICK_INDEX = 0xffffffff
9
+ const PUBLIC_KEY = PublicKey.default
10
+
11
+ const preciseRaw = (value: bigint) => value * PRECISE_NUMBER_DENOM
12
+
13
+ const marketEmissions: MarketThree["emissions"] = { trackers: [] }
14
+
15
+ const makeTick = (overrides: Partial<Tick> = {}): Tick => ({
16
+ apyBasePoints: 1,
17
+ impliedRate: 1.0,
18
+ principalPt: 0n,
19
+ principalSy: 0n,
20
+ principalShareSupply: 0n,
21
+ liquidityNet: 0n,
22
+ liquidityGross: 0n,
23
+ feeGrowthOutsidePt: 0n,
24
+ feeGrowthOutsideSy: 0n,
25
+ farms: [],
26
+ emissions: [],
27
+ lastSplitEpoch: 0n,
28
+ splitParentEpoch: 0n,
29
+ frozenLiquidity: 0n,
30
+ splitParentIdx: SENTINEL_TICK_INDEX,
31
+ ...overrides,
32
+ })
33
+
34
+ const makeTicks = (ticksTree: Tick[]): Ticks => ({
35
+ currentTick: 1,
36
+ ticksTree,
37
+ market: PUBLIC_KEY,
38
+ feeGrowthIndexGlobalPt: 0n,
39
+ feeGrowthIndexGlobalSy: 0n,
40
+ currentPrefixSum: 0n,
41
+ currentSpotPrice: 1,
42
+ })
43
+
44
+ const makePosition = (overrides: Partial<LpPositionCLMM> = {}): LpPositionCLMM => ({
45
+ owner: PUBLIC_KEY,
46
+ market: PUBLIC_KEY,
47
+ feeInsideLastPt: 0n,
48
+ feeInsideLastSy: 0n,
49
+ lpBalance: 1n,
50
+ tokensOwedSy: 0n,
51
+ tokensOwedPt: 0n,
52
+ lowerTickIdx: 1,
53
+ upperTickIdx: 2,
54
+ farms: [],
55
+ shareTrackers: [
56
+ {
57
+ tickIdx: 1,
58
+ rightTickIdx: 2,
59
+ splitEpoch: 0n,
60
+ lpShare: PRECISE_NUMBER_DENOM / 2n,
61
+ emissions: [],
62
+ },
63
+ ],
64
+ crossingSplit: {
65
+ crossLeftIdx: SENTINEL_TICK_INDEX,
66
+ crossRightIdx: SENTINEL_TICK_INDEX,
67
+ lpBalanceCrossing: 0n,
68
+ isActive: false,
69
+ },
70
+ ...overrides,
71
+ })
72
+
73
+ describe("withdraw liquidity math", () => {
74
+ it("uses precise Number-style principal math for fractional shares", () => {
75
+ const ticks = makeTicks([
76
+ makeTick({
77
+ principalPt: 7n,
78
+ principalSy: 11n,
79
+ principalShareSupply: PRECISE_NUMBER_DENOM,
80
+ }),
81
+ makeTick({ apyBasePoints: 2, impliedRate: 2 }),
82
+ ])
83
+
84
+ expect(getPtAndSyOnWithdrawLiquidity(marketEmissions, ticks, makePosition(), 1n)).toEqual({
85
+ totalPtOut: 3n,
86
+ totalSyOut: 5n,
87
+ })
88
+ })
89
+
90
+ it("returns full principal when burned shares cover the supply", () => {
91
+ const ticks = makeTicks([
92
+ makeTick({
93
+ principalPt: 7n,
94
+ principalSy: 11n,
95
+ principalShareSupply: PRECISE_NUMBER_DENOM,
96
+ }),
97
+ makeTick({ apyBasePoints: 2, impliedRate: 2 }),
98
+ ])
99
+ const position = makePosition({
100
+ shareTrackers: [
101
+ {
102
+ tickIdx: 1,
103
+ rightTickIdx: 2,
104
+ splitEpoch: 0n,
105
+ lpShare: PRECISE_NUMBER_DENOM,
106
+ emissions: [],
107
+ },
108
+ ],
109
+ })
110
+
111
+ expect(getPtAndSyOnWithdrawLiquidity(marketEmissions, ticks, position, 1n)).toEqual({
112
+ totalPtOut: 7n,
113
+ totalSyOut: 11n,
114
+ })
115
+ })
116
+
117
+ it("rejects burn shares that exceed tick supply", () => {
118
+ const ticks = makeTicks([
119
+ makeTick({
120
+ principalPt: 7n,
121
+ principalSy: 11n,
122
+ principalShareSupply: PRECISE_NUMBER_DENOM / 2n,
123
+ }),
124
+ makeTick({ apyBasePoints: 2, impliedRate: 2 }),
125
+ ])
126
+ const position = makePosition({
127
+ shareTrackers: [
128
+ {
129
+ tickIdx: 1,
130
+ rightTickIdx: 2,
131
+ splitEpoch: 0n,
132
+ lpShare: PRECISE_NUMBER_DENOM,
133
+ emissions: [],
134
+ },
135
+ ],
136
+ })
137
+
138
+ expect(() => getPtAndSyOnWithdrawLiquidity(marketEmissions, ticks, position, 1n)).toThrow(
139
+ "withdraw burn shares exceed tick supply",
140
+ )
141
+ })
142
+
143
+ it("projects split shares through split parent epoch instead of immediate successor", () => {
144
+ const ticks = makeTicks([
145
+ makeTick({
146
+ apyBasePoints: 100,
147
+ impliedRate: 1.0,
148
+ principalPt: 50n,
149
+ principalShareSupply: preciseRaw(50n),
150
+ lastSplitEpoch: 1n,
151
+ }),
152
+ makeTick({
153
+ apyBasePoints: 125,
154
+ impliedRate: 1.25,
155
+ principalPt: 999n,
156
+ principalShareSupply: preciseRaw(50n),
157
+ splitParentEpoch: 99n,
158
+ splitParentIdx: 1,
159
+ }),
160
+ makeTick({
161
+ apyBasePoints: 150,
162
+ impliedRate: 1.5,
163
+ principalPt: 300n,
164
+ principalShareSupply: preciseRaw(50n),
165
+ splitParentEpoch: 0n,
166
+ splitParentIdx: 1,
167
+ }),
168
+ makeTick({
169
+ apyBasePoints: 200,
170
+ impliedRate: 2.0,
171
+ }),
172
+ ])
173
+ const position = makePosition({
174
+ lpBalance: 100n,
175
+ upperTickIdx: 4,
176
+ shareTrackers: [
177
+ {
178
+ tickIdx: 1,
179
+ rightTickIdx: 4,
180
+ splitEpoch: 0n,
181
+ lpShare: preciseRaw(100n),
182
+ emissions: [],
183
+ },
184
+ ],
185
+ })
186
+
187
+ expect(getPtAndSyOnWithdrawLiquidity(marketEmissions, ticks, position, 100n).totalPtOut).toBe(350n)
188
+ })
189
+ })
@@ -1,6 +1,88 @@
1
- import { LpPositionCLMM, MarketThree, Ticks } from "@exponent-labs/exponent-fetcher"
1
+ import type { LpPositionCLMM, MarketThree, Ticks } from "@exponent-labs/exponent-fetcher"
2
2
 
3
- import { getSuccessorTickIdxByIdx } from "./utils"
3
+ const PRECISE_NUMBER_DENOM = 1_000_000_000_000n
4
+ const SENTINEL_TICK_INDEX = 0xffffffff
5
+
6
+ const invariantError = (message: string) => new Error(`WithdrawLiquidityInvariantViolated: ${message}`)
7
+
8
+ const getTickByIndex = (ticks: Ticks, tickIdx: number) => {
9
+ const tick = ticks.ticksTree[tickIdx - 1]
10
+ if (!tick || tick.apyBasePoints <= 0) {
11
+ throw invariantError(`missing tick at index ${tickIdx}`)
12
+ }
13
+
14
+ return tick
15
+ }
16
+
17
+ const getTickKey = (ticks: Ticks, tickIdx: number) => getTickByIndex(ticks, tickIdx).apyBasePoints
18
+
19
+ const successorIdx = (ticks: Ticks, tickIdx: number): number | null => {
20
+ const tickKey = getTickKey(ticks, tickIdx)
21
+ let best: { tickIdx: number; key: number } | null = null
22
+
23
+ for (let index = 0; index < ticks.ticksTree.length; index++) {
24
+ const key = ticks.ticksTree[index].apyBasePoints
25
+ if (key <= tickKey) {
26
+ continue
27
+ }
28
+ if (!best || key < best.key) {
29
+ best = { tickIdx: index + 1, key }
30
+ }
31
+ }
32
+
33
+ return best?.tickIdx ?? null
34
+ }
35
+
36
+ const splitSuccessorForEpoch = (ticks: Ticks, leftIdx: number, rightIdx: number, splitEpoch: bigint): number | null => {
37
+ let cursor = successorIdx(ticks, leftIdx)
38
+ while (cursor != null && cursor !== SENTINEL_TICK_INDEX && cursor !== rightIdx) {
39
+ const tick = getTickByIndex(ticks, cursor)
40
+ if (tick.splitParentIdx === leftIdx && tick.splitParentEpoch === splitEpoch) {
41
+ return cursor
42
+ }
43
+ cursor = successorIdx(ticks, cursor)
44
+ }
45
+
46
+ return null
47
+ }
48
+
49
+ const fastMulRatioRaw = (rawPreciseNumber: bigint, numerator: bigint, denominator: bigint): bigint => {
50
+ if (denominator <= 0n) {
51
+ throw invariantError("fastMulRatio denominator is zero")
52
+ }
53
+ if (numerator === 0n || rawPreciseNumber === 0n) {
54
+ return 0n
55
+ }
56
+ if (numerator === denominator) {
57
+ return rawPreciseNumber
58
+ }
59
+
60
+ const q = rawPreciseNumber / denominator
61
+ const r = rawPreciseNumber % denominator
62
+ return q * numerator + (r * numerator) / denominator
63
+ }
64
+
65
+ const numberFromRatioRaw = (numerator: bigint, denominator: bigint): bigint => {
66
+ if (denominator <= 0n) {
67
+ throw invariantError("Number::from_ratio denominator is zero")
68
+ }
69
+
70
+ return (numerator * PRECISE_NUMBER_DENOM) / denominator
71
+ }
72
+
73
+ const mulNumberRaw = (leftRaw: bigint, rightRaw: bigint): bigint => (leftRaw * rightRaw) / PRECISE_NUMBER_DENOM
74
+
75
+ const principalOutForBurn = (principal: bigint, burnShares: bigint, shareSupply: bigint): bigint => {
76
+ if (principal === 0n || burnShares === 0n || shareSupply === 0n) {
77
+ return 0n
78
+ }
79
+ if (burnShares >= shareSupply) {
80
+ return principal
81
+ }
82
+
83
+ const out = (principal * burnShares) / shareSupply
84
+ return out < principal ? out : principal
85
+ }
4
86
 
5
87
  /**
6
88
  * TypeScript version of project_anchor_shares_to_current_ticks from Rust
@@ -13,13 +95,10 @@ import { getSuccessorTickIdxByIdx } from "./utils"
13
95
  * @param rootShares - The original share trackers to project
14
96
  * @returns Array of projected principal shares
15
97
  *
16
- * Note: Requires TicksExtended with lastSplitEpoch and emissions data.
17
- * The current exponent-fetcher deserializer would need to be updated to extract these fields.
98
+ * Note: Requires TicksExtended with split-parent metadata and emissions data.
18
99
  */
19
100
  type ShareTracker = LpPositionCLMM["shareTrackers"][0]
20
101
  function projectAnchorSharesToCurrentTicks(ticks: Ticks, rootShares: ShareTracker[]): ShareTracker[] {
21
- const SENTINEL = 0xffffffff // Sentinel value for tree traversal (matches Rust implementation)
22
-
23
102
  // Deep clone the trackers to avoid mutating the original objects (important for React Query caching)
24
103
  const stack: ShareTracker[] = rootShares.map((s) => ({
25
104
  tickIdx: s.tickIdx,
@@ -32,14 +111,7 @@ function projectAnchorSharesToCurrentTicks(ticks: Ticks, rootShares: ShareTracke
32
111
 
33
112
  while (stack.length > 0) {
34
113
  const principalShare = stack.pop()!
35
-
36
- // Find the tick node for this share
37
- const tickNode = ticks.ticksTree.at(principalShare.tickIdx - 1) ?? null
38
-
39
- if (!tickNode) {
40
- //? Tick node not found for tickIdx
41
- continue
42
- }
114
+ const tickNode = getTickByIndex(ticks, principalShare.tickIdx)
43
115
 
44
116
  const lastSplitEpoch = tickNode.lastSplitEpoch
45
117
 
@@ -47,60 +119,54 @@ function projectAnchorSharesToCurrentTicks(ticks: Ticks, rootShares: ShareTracke
47
119
  if (principalShare.splitEpoch < lastSplitEpoch) {
48
120
  const rightIndex = principalShare.rightTickIdx
49
121
 
50
- if (rightIndex !== SENTINEL) {
51
- // Find the successor tick (the split point)
52
- const splitedIndex = getSuccessorTickIdxByIdx(ticks, principalShare.tickIdx)
53
-
54
- if (splitedIndex === null) {
55
- //? No successor tick found for splitting
56
- continue
57
- }
58
-
59
- const tickSpotPrice = tickNode.impliedRate
60
- const rightTickNode = ticks.ticksTree.at(rightIndex - 1) ?? null
61
- const splitedTickNode = ticks.ticksTree.at(splitedIndex - 1) ?? null
62
-
63
- if (!rightTickNode || !splitedTickNode) {
64
- //? Could not find right or split tick nodes
65
- continue
66
- }
67
-
68
- // Calculate the proportions based on spot price ranges
69
- const splitedFullRange = rightTickNode.impliedRate - tickSpotPrice
70
- const currentSplitRange = splitedTickNode.impliedRate - tickSpotPrice
71
-
72
- // Calculate how much LP share goes to the left portion
73
- const leftShare = Math.floor(Number(principalShare.lpShare) * (currentSplitRange / splitedFullRange))
74
-
75
- // Create new emission trackers with staged reset to 0
76
- const newEmissions: ShareTracker["emissions"] = principalShare.emissions.map((tracker) => ({
77
- staged: 0n,
78
- lastSeenIndex: tracker.lastSeenIndex,
79
- }))
80
-
81
- // Calculate the migrated share (right portion)
82
- const migratedShare = principalShare.lpShare - BigInt(leftShare)
83
-
84
- // Update the current share to be the left portion
85
- principalShare.lpShare = BigInt(leftShare)
86
- principalShare.rightTickIdx = splitedIndex
87
-
88
- // Push the right portion back onto the stack for further processing
89
- stack.push({
90
- tickIdx: splitedIndex,
91
- rightTickIdx: rightIndex,
92
- splitEpoch: principalShare.splitEpoch,
93
- lpShare: migratedShare,
94
- emissions: newEmissions,
95
- })
96
-
97
- // Update the split epoch to mark this share as processed
98
- principalShare.splitEpoch = lastSplitEpoch
99
- } else {
100
- // Error: we have a split range but no right index
101
- //? No right index for split range
102
- continue
122
+ if (rightIndex === SENTINEL_TICK_INDEX) {
123
+ throw invariantError("no right index for split range")
103
124
  }
125
+
126
+ const splitedIndex = splitSuccessorForEpoch(ticks, principalShare.tickIdx, rightIndex, principalShare.splitEpoch)
127
+ if (splitedIndex == null) {
128
+ throw invariantError("no split successor for parent epoch")
129
+ }
130
+
131
+ const tickSpotPrice = tickNode.impliedRate
132
+ const rightTickNode = getTickByIndex(ticks, rightIndex)
133
+ const splitedTickNode = getTickByIndex(ticks, splitedIndex)
134
+
135
+ const splitedFullRange = rightTickNode.impliedRate - tickSpotPrice
136
+ const currentSplitRange = splitedTickNode.impliedRate - tickSpotPrice
137
+
138
+ const [leftShare, migratedShare] =
139
+ splitedFullRange <= 0
140
+ ? [0n, principalShare.lpShare]
141
+ : (() => {
142
+ const ratio = Math.max(0, Math.min(1, currentSplitRange / splitedFullRange))
143
+ const scaledNum = BigInt(Math.round(ratio * Number(PRECISE_NUMBER_DENOM)))
144
+ const rightScaledNum = PRECISE_NUMBER_DENOM - scaledNum
145
+ return [
146
+ fastMulRatioRaw(principalShare.lpShare, scaledNum, PRECISE_NUMBER_DENOM),
147
+ fastMulRatioRaw(principalShare.lpShare, rightScaledNum, PRECISE_NUMBER_DENOM),
148
+ ]
149
+ })()
150
+
151
+ const newEmissions: ShareTracker["emissions"] = principalShare.emissions.map((tracker) => ({
152
+ staged: 0n,
153
+ lastSeenIndex: tracker.lastSeenIndex,
154
+ }))
155
+
156
+ principalShare.lpShare = leftShare
157
+ principalShare.rightTickIdx = splitedIndex
158
+
159
+ stack.push({
160
+ tickIdx: splitedIndex,
161
+ rightTickIdx: rightIndex,
162
+ splitEpoch: principalShare.splitEpoch,
163
+ lpShare: migratedShare,
164
+ emissions: newEmissions,
165
+ })
166
+
167
+ principalShare.splitEpoch += 1n
168
+ stack.push(principalShare)
169
+ continue
104
170
  }
105
171
 
106
172
  // Add the processed share to the result
@@ -138,14 +204,7 @@ function updateLpPositionShares(
138
204
  // Iterate through each share and update
139
205
  for (const share of recomputedShares) {
140
206
  const myShares = share.lpShare
141
-
142
- // Find the tick node in the ticks tree
143
- const tickNode = ticks.ticksTree.at(share.tickIdx - 1) ?? null
144
-
145
- if (!tickNode) {
146
- //? Tick node not found for provided tickIdx
147
- continue
148
- }
207
+ const tickNode = getTickByIndex(ticks, share.tickIdx)
149
208
 
150
209
  // Update tick emissions with market emission indices
151
210
  // In Rust: node_mut.value.update_tick_emissions(market_emission_indices)
@@ -202,28 +261,28 @@ function calculatePtSyRemoval(
202
261
  ): { totalPtOut: bigint; totalSyOut: bigint } {
203
262
  let totalPtOut = 0n
204
263
  let totalSyOut = 0n
264
+ const isFullRemoval = liquidityToRemove === position.lpBalance
265
+
266
+ if (liquidityToRemove <= 0n || liquidityToRemove > position.lpBalance) {
267
+ throw invariantError("invalid liquidity amount")
268
+ }
205
269
 
206
270
  for (const share of position.shareTrackers) {
207
271
  const myShares = share.lpShare
208
- const tickNode = ticks.ticksTree.at(share.tickIdx - 1) ?? null
209
-
210
- if (!tickNode) {
211
- continue
212
- }
272
+ const tickNode = getTickByIndex(ticks, share.tickIdx)
213
273
 
214
274
  const supply = tickNode.principalShareSupply
215
275
 
216
- // Calculate burn shares: r = l_remove / pos.L
217
- // burn_shares = my_shares * liquidity_to_remove / position.lp_balance
218
- const burnShares = (myShares * liquidityToRemove) / position.lpBalance
276
+ const burnShares = isFullRemoval
277
+ ? myShares
278
+ : mulNumberRaw(myShares, numberFromRatioRaw(liquidityToRemove, position.lpBalance))
219
279
 
220
- // Calculate PT output for this tick
221
- // pt_out = principal_pt * burn_shares / supply
222
- const ptOut = supply > 0n ? (tickNode.principalPt * burnShares) / supply : 0n
280
+ if (burnShares > supply) {
281
+ throw invariantError("withdraw burn shares exceed tick supply")
282
+ }
223
283
 
224
- // Calculate SY output for this tick
225
- // sy_out = principal_sy * burn_shares / supply
226
- const syOut = supply > 0n ? (tickNode.principalSy * burnShares) / supply : 0n
284
+ const ptOut = principalOutForBurn(tickNode.principalPt, burnShares, supply)
285
+ const syOut = principalOutForBurn(tickNode.principalSy, burnShares, supply)
227
286
 
228
287
  totalPtOut += ptOut
229
288
  totalSyOut += syOut