@exponent-labs/market-three-math 0.9.12 → 0.9.14

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.
@@ -2,6 +2,8 @@
2
2
  * CLMM Add Liquidity simulation
3
3
  * Ported from exponent_clmm/src/state/market_three/helpers/add_liquidity.rs
4
4
  */
5
+ import { Ticks } from "@exponent-labs/exponent-fetcher"
6
+
5
7
  import { simulateSwap, simulateSwapExactOut } from "./swapV2"
6
8
  import {
7
9
  AddLiquidityArgs,
@@ -12,15 +14,22 @@ import {
12
14
  MarketThreeState,
13
15
  SwapDirection,
14
16
  } from "./types"
15
- import { EffSnap, normalizedTimeRemaining, TicksWrapper } from "./utilsV2"
16
17
  import { findTickByKey, getSuccessorTickKey } from "./utils"
17
- import { Ticks } from "@exponent-labs/exponent-fetcher"
18
+ import { EffSnap, TicksWrapper, normalizedTimeRemaining } from "./utilsV2"
18
19
 
19
20
  const TICK_KEY_BASE_POINTS = 1_000_000
20
21
  // Keep this in sync with exponent_clmm's add_liquidity helper so off-chain
21
22
  // 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
23
+ const GAP_TOKEN_NEEDS = 20
24
+ const MIN_SWAP_EXACT_OUT_SY_HEADROOM = 100
25
+ const SWAP_EXACT_OUT_SY_HEADROOM = 100
26
+ const SWAP_EXACT_OUT_HEADROOM_PT_THRESHOLD = 10_000_000
27
+ const MIN_SWAP_AND_SUPPLY_SY_BUDGET = 1_000_000
28
+ const FLASH_SY_POSITION_HEADROOM = 1_000
29
+ const MAX_SWAP_AND_SUPPLY_UNDERSPEND_LAMPORTS = 1_000
30
+ const MAX_SWAP_AND_SUPPLY_SIMULATED_OVERSPEND_LAMPORTS = 0
31
+ const LARGE_SWAP_AND_SUPPLY_SY_BUDGET = 50_000_000_000
32
+ const PT_ONLY_SWAP_EXACT_OUT_ESTIMATE_SLACK = 0
24
33
 
25
34
  const EMPTY_CROSSING_TICK_STATE: CrossingTickState = {
26
35
  principalPt: 0,
@@ -416,12 +425,12 @@ function simulateAccruePrincipalForDeposit(params: {
416
425
  return predecessor ?? candidateKey
417
426
  }
418
427
 
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) => {
428
+ const visitIntervals = (
429
+ startKey: number,
430
+ priceStart: number,
431
+ priceEnd: number,
432
+ visitor: (params: { leftKey: number; rightKey: number; leftPrice: number; rightPrice: number }) => void,
433
+ ) => {
425
434
  if (!(priceStart < priceEnd)) {
426
435
  return
427
436
  }
@@ -953,8 +962,7 @@ export function simulateWrapperProvideLiquidity(
953
962
  const priceEffUpper = snap.getEffectivePrice(upperPrice)
954
963
 
955
964
  // Step 3: Get crossing tick state (matches wrapper_provide_liquidity.rs)
956
- const { crossingTickState, crossingTickPriceLeft, crossingTickPriceRight } =
957
- getCrossingTickStateFromTicks(ticks)
965
+ const { crossingTickState, crossingTickPriceLeft, crossingTickPriceRight } = getCrossingTickStateFromTicks(ticks)
958
966
 
959
967
  // Step 4: Calculate mock token needs using compute_token_needs_with_crossing
960
968
  // max_sy = syAmount, max_pt = syAmount * syExchangeRate (as on-chain)
@@ -997,6 +1005,10 @@ export function simulateWrapperProvideLiquidity(
997
1005
  syExchangeRate,
998
1006
  })
999
1007
 
1008
+ if (!hasLpSupplyCapacity(marketState, depositResult.deltaL)) {
1009
+ return null
1010
+ }
1011
+
1000
1012
  return {
1001
1013
  lpOut: depositResult.deltaL,
1002
1014
  ytOut: Math.floor(ytOut),
@@ -1047,9 +1059,9 @@ export function simulateSwapAndSupply(
1047
1059
  // Wrapper provide-liquidity-base debits base as:
1048
1060
  // base_needed = ceil(total_sy_spent * sy_exchange_rate)
1049
1061
  // So the strict SY budget for a user-provided base input is floor(base / rate).
1050
- const syBudget = convertBaseToSyBudget(amountBase)
1062
+ const syBudget = convertBaseToSyBudget(amountBase, syExchangeRate)
1051
1063
 
1052
- if (syBudget <= 0) {
1064
+ if (syBudget <= 0 || syBudget < MIN_SWAP_AND_SUPPLY_SY_BUDGET) {
1053
1065
  return {
1054
1066
  lpOut: 0,
1055
1067
  ptToBuy: 0,
@@ -1062,6 +1074,11 @@ export function simulateSwapAndSupply(
1062
1074
  }
1063
1075
  }
1064
1076
 
1077
+ const flashSyAvailable = Math.max(0, Number(marketState.financials.syBalance) - FLASH_SY_POSITION_HEADROOM)
1078
+ if (syBudget > flashSyAvailable) {
1079
+ return null
1080
+ }
1081
+
1065
1082
  const MOCK_AMOUNT = 1e9
1066
1083
  const mockNeeds = simulateAddLiquidity(marketState, {
1067
1084
  lowerTick,
@@ -1070,8 +1087,6 @@ export function simulateSwapAndSupply(
1070
1087
  maxPt: MOCK_AMOUNT,
1071
1088
  syExchangeRate,
1072
1089
  })
1073
- const searchBudget = Math.max(0, syBudget - SWAP_EXACT_OUT_SY_HEADROOM)
1074
-
1075
1090
  // Wrapper flow still executes a flash swap even when external_pt_to_buy is 0
1076
1091
  // (effective exact-out target becomes 2 due +2 safety margin).
1077
1092
  let externalPtToBuy = 0
@@ -1103,6 +1118,15 @@ export function simulateSwapAndSupply(
1103
1118
  externalPtToBuy = Math.max(0, Math.floor(guessSwap.amountOut))
1104
1119
  }
1105
1120
  }
1121
+ const baseSwapExactOutSyHeadroom =
1122
+ externalPtToBuy >= SWAP_EXACT_OUT_HEADROOM_PT_THRESHOLD
1123
+ ? SWAP_EXACT_OUT_SY_HEADROOM
1124
+ : MIN_SWAP_EXACT_OUT_SY_HEADROOM
1125
+ const swapExactOutSyHeadroom = syBudget >= LARGE_SWAP_AND_SUPPLY_SY_BUDGET ? 0 : baseSwapExactOutSyHeadroom
1126
+ const simulatedOverspendTolerance =
1127
+ syBudget >= LARGE_SWAP_AND_SUPPLY_SY_BUDGET ? MAX_SWAP_AND_SUPPLY_SIMULATED_OVERSPEND_LAMPORTS : 0
1128
+ const searchBudget = Math.max(0, syBudget - swapExactOutSyHeadroom + simulatedOverspendTolerance)
1129
+ const exactOutEstimateSlack = mockNeeds.sySpent === 0 ? PT_ONLY_SWAP_EXACT_OUT_ESTIMATE_SLACK : 0
1106
1130
 
1107
1131
  const candidateCache = new Map<number, SwapAndSupplyCandidate | null>()
1108
1132
  const evaluateCandidate = (ptConstraint: number) => {
@@ -1118,13 +1142,35 @@ export function simulateSwapAndSupply(
1118
1142
  syBudget,
1119
1143
  key,
1120
1144
  syExchangeRate,
1145
+ exactOutEstimateSlack,
1121
1146
  )
1122
1147
  candidateCache.set(key, candidate)
1123
1148
  return candidate
1124
1149
  }
1150
+
1151
+ if (mockNeeds.ptSpent <= 0) {
1152
+ const selected = evaluateCandidate(0)
1153
+ if (!selected || !hasLpSupplyCapacity(marketState, selected.lpOut)) {
1154
+ return null
1155
+ }
1156
+ if (!isSyDebitWithinTolerance(syBudget, selected.totalSySpent, simulatedOverspendTolerance)) {
1157
+ return null
1158
+ }
1159
+
1160
+ return {
1161
+ lpOut: selected.lpOut,
1162
+ ptToBuy: 0,
1163
+ syConstraint: syBudget,
1164
+ syForSwap: selected.tradeSySpent,
1165
+ syRemainder: selected.syRemainderAfterSwap,
1166
+ ptFromSwap: selected.tradePtOut,
1167
+ syDeposited: selected.depositSySpent,
1168
+ ptDeposited: selected.depositPtSpent,
1169
+ }
1170
+ }
1171
+
1125
1172
  const ptCap = 1_000_000_000
1126
1173
  let bestUnderBudget: SwapAndSupplyCandidate | null = null
1127
- let leastOverspend: SwapAndSupplyCandidate | null = null
1128
1174
  const considerCandidate = (candidate: SwapAndSupplyCandidate | null) => {
1129
1175
  if (!candidate) {
1130
1176
  return
@@ -1139,14 +1185,6 @@ export function simulateSwapAndSupply(
1139
1185
  }
1140
1186
  return
1141
1187
  }
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
1188
  }
1151
1189
 
1152
1190
  // Always evaluate from zero to establish a valid lower bound.
@@ -1200,14 +1238,21 @@ export function simulateSwapAndSupply(
1200
1238
  considerCandidate(evaluateCandidate(pt))
1201
1239
  }
1202
1240
 
1203
- const selected = bestUnderBudget ?? leastOverspend
1241
+ const selected = bestUnderBudget
1204
1242
  if (!selected) {
1205
- throw new Error("Unable to simulate swap & supply for initial PT constraint")
1243
+ throw new Error("Unable to simulate swap & supply within SY budget")
1244
+ }
1245
+ if (!hasLpSupplyCapacity(marketState, selected.lpOut)) {
1246
+ return null
1247
+ }
1248
+ if (!isSyDebitWithinTolerance(syBudget, selected.totalSySpent, simulatedOverspendTolerance)) {
1249
+ return null
1206
1250
  }
1207
1251
  if (process.env.DEBUG_SWAP_SUPPLY === "1") {
1208
1252
  console.log("[simulateSwapAndSupply]", {
1209
1253
  syBudget,
1210
1254
  searchBudget,
1255
+ swapExactOutSyHeadroom,
1211
1256
  mockNeeds,
1212
1257
  externalPtToBuy,
1213
1258
  selected,
@@ -1248,6 +1293,7 @@ function simulateSwapAndSupplyForPtConstraint(
1248
1293
  syBudget: number,
1249
1294
  ptConstraint: number,
1250
1295
  syExchangeRate: number,
1296
+ exactOutEstimateSlack: number,
1251
1297
  ): SwapAndSupplyCandidate | null {
1252
1298
  let swapResult: {
1253
1299
  sySpent: number
@@ -1261,6 +1307,7 @@ function simulateSwapAndSupplyForPtConstraint(
1261
1307
  syBudget,
1262
1308
  ptConstraint,
1263
1309
  syExchangeRate,
1310
+ exactOutEstimateSlack,
1264
1311
  )
1265
1312
  } catch {
1266
1313
  // Budget insufficient for this PT constraint
@@ -1268,7 +1315,6 @@ function simulateSwapAndSupplyForPtConstraint(
1268
1315
  }
1269
1316
 
1270
1317
  const depositState = swapResult.postMarketState ?? marketState
1271
- const syAvailableForDeposit = Math.max(0, syBudget - swapResult.sySpent)
1272
1318
  const depositResult = simulateAddLiquidity(depositState, {
1273
1319
  lowerTick,
1274
1320
  upperTick,
@@ -1302,9 +1348,39 @@ function simulateSwapAndSupplyForPtConstraint(
1302
1348
  }
1303
1349
  }
1304
1350
 
1305
- function convertBaseToSyBudget(amountBase: number): number {
1351
+ function convertBaseToSyBudget(amountBase: number, syExchangeRate: number): number {
1306
1352
  const baseAmount = Math.max(0, Math.floor(amountBase))
1307
- return baseAmount
1353
+ if (syExchangeRate <= 0) {
1354
+ return 0
1355
+ }
1356
+ return Math.floor(baseAmount / syExchangeRate)
1357
+ }
1358
+
1359
+ function hasLpSupplyCapacity(marketState: MarketThreeState, deltaL: number): boolean {
1360
+ const deltaLiquidity = Math.ceil(deltaL)
1361
+ if (!Number.isFinite(deltaLiquidity) || deltaLiquidity < 0 || !Number.isSafeInteger(deltaLiquidity)) {
1362
+ return false
1363
+ }
1364
+
1365
+ const liquidityBalance = BigInt(marketState.financials.liquidityBalance.toString())
1366
+ const maxLpSupply = BigInt(marketState.configurationOptions.maxLpSupply.toString())
1367
+
1368
+ return liquidityBalance + BigInt(deltaLiquidity) <= maxLpSupply
1369
+ }
1370
+
1371
+ function isSyDebitWithinTolerance(
1372
+ syBudget: number,
1373
+ totalSySpent: number,
1374
+ simulatedOverspendTolerance: number,
1375
+ ): boolean {
1376
+ const targetSyDebit = Math.max(0, Math.floor(syBudget))
1377
+ const estimatedSyDebit = Math.max(0, Math.floor(totalSySpent))
1378
+ const underSpendLamports = targetSyDebit - estimatedSyDebit
1379
+ if (underSpendLamports < 0) {
1380
+ return Math.abs(underSpendLamports) <= simulatedOverspendTolerance
1381
+ }
1382
+
1383
+ return underSpendLamports <= MAX_SWAP_AND_SUPPLY_UNDERSPEND_LAMPORTS
1308
1384
  }
1309
1385
 
1310
1386
  function simulateBuyPtExactOutWrapper(
@@ -1312,12 +1388,14 @@ function simulateBuyPtExactOutWrapper(
1312
1388
  syBudget: number,
1313
1389
  ptConstraint: number,
1314
1390
  syExchangeRate: number,
1391
+ exactOutEstimateSlack: number,
1315
1392
  ): {
1316
1393
  sySpent: number
1317
1394
  ptOut: number
1318
1395
  postMarketState?: MarketThreeState
1319
1396
  } {
1320
1397
  const maxSyBudget = Math.max(0, Math.floor(syBudget))
1398
+ const exactOutSyConstraint = maxSyBudget + Math.max(0, Math.floor(exactOutEstimateSlack))
1321
1399
  const effectivePtOutTarget = Math.max(0, Math.floor(ptConstraint)) + 2
1322
1400
 
1323
1401
  if (maxSyBudget === 0) {
@@ -1334,7 +1412,7 @@ function simulateBuyPtExactOutWrapper(
1334
1412
  amountOut: targetPtOut,
1335
1413
  syExchangeRate,
1336
1414
  isCurrentFlashSwap: true,
1337
- amountInConstraint: maxSyBudget,
1415
+ amountInConstraint: exactOutSyConstraint,
1338
1416
  })
1339
1417
  }
1340
1418
 
@@ -0,0 +1,162 @@
1
+ import { PublicKey } from "@solana/web3.js"
2
+
3
+ import type { LpPositionCLMM, Ticks } from "@exponent-labs/exponent-fetcher"
4
+
5
+ import {
6
+ computeExistingPositionBudgetEffect,
7
+ computeExistingPositionEqualization,
8
+ computeRequiredUserMaxForExistingPositionEqualization,
9
+ } from "./existingPositionEqualization"
10
+
11
+ const PRECISE_NUMBER_DENOM = 1_000_000_000_000n
12
+ const SENTINEL_TICK_INDEX = 0xffffffff
13
+ const PUBLIC_KEY = PublicKey.default
14
+
15
+ const preciseRaw = (value: bigint) => value * PRECISE_NUMBER_DENOM
16
+
17
+ const makeTicks = (): Ticks => ({
18
+ currentTick: 1,
19
+ ticksTree: [
20
+ {
21
+ apyBasePoints: 63_000,
22
+ impliedRate: 1.063,
23
+ principalPt: 500n,
24
+ principalSy: 700n,
25
+ principalShareSupply: preciseRaw(100n),
26
+ liquidityNet: 0n,
27
+ liquidityGross: 0n,
28
+ feeGrowthOutsidePt: 0n,
29
+ feeGrowthOutsideSy: 0n,
30
+ farms: [],
31
+ emissions: [],
32
+ lastSplitEpoch: 0n,
33
+ frozenLiquidity: 0n,
34
+ },
35
+ {
36
+ apyBasePoints: 65_000,
37
+ impliedRate: 1.065,
38
+ principalPt: 0n,
39
+ principalSy: 0n,
40
+ principalShareSupply: 0n,
41
+ liquidityNet: 0n,
42
+ liquidityGross: 0n,
43
+ feeGrowthOutsidePt: 0n,
44
+ feeGrowthOutsideSy: 0n,
45
+ farms: [],
46
+ emissions: [],
47
+ lastSplitEpoch: 0n,
48
+ frozenLiquidity: 0n,
49
+ },
50
+ ],
51
+ market: PUBLIC_KEY,
52
+ feeGrowthIndexGlobalPt: 0n,
53
+ feeGrowthIndexGlobalSy: 0n,
54
+ currentPrefixSum: 0n,
55
+ currentSpotPrice: 1.062,
56
+ })
57
+
58
+ const makePosition = (): LpPositionCLMM => ({
59
+ owner: PUBLIC_KEY,
60
+ market: PUBLIC_KEY,
61
+ feeInsideLastPt: 0n,
62
+ feeInsideLastSy: 0n,
63
+ lpBalance: 200n,
64
+ tokensOwedSy: 0n,
65
+ tokensOwedPt: 0n,
66
+ lowerTickIdx: 1,
67
+ upperTickIdx: 2,
68
+ farms: [],
69
+ shareTrackers: [
70
+ {
71
+ tickIdx: 1,
72
+ rightTickIdx: 2,
73
+ splitEpoch: 0n,
74
+ lpShare: preciseRaw(100n),
75
+ emissions: [],
76
+ },
77
+ ],
78
+ crossingSplit: {
79
+ crossLeftIdx: 1,
80
+ crossRightIdx: 2,
81
+ lpBalanceCrossing: 100n,
82
+ isActive: true,
83
+ },
84
+ })
85
+
86
+ describe("existing position equalization", () => {
87
+ it("matches the contract add-direction budget math with integer rounding", () => {
88
+ const equalization = computeExistingPositionEqualization(makeTicks(), makePosition())
89
+
90
+ expect(equalization.sySpent).toBe(700n)
91
+ expect(equalization.ptSpent).toBe(500n)
92
+ expect(equalization.syReleased).toBe(0n)
93
+ expect(equalization.ptReleased).toBe(0n)
94
+ expect(equalization.plan).toEqual([
95
+ {
96
+ shareIndex: 0,
97
+ tickIdx: 1,
98
+ direction: "add",
99
+ shareDeltaRaw: preciseRaw(100n),
100
+ ptDelta: 500n,
101
+ syDelta: 700n,
102
+ },
103
+ ])
104
+ })
105
+
106
+ it("throws when user budgets cannot cover fixed equalization spend", () => {
107
+ expect(() =>
108
+ computeExistingPositionBudgetEffect({
109
+ ticks: makeTicks(),
110
+ position: makePosition(),
111
+ userMaxSy: 700n,
112
+ userMaxPt: 499n,
113
+ }),
114
+ ).toThrow("CrossingEqualizationInvariantViolated")
115
+
116
+ expect(
117
+ computeExistingPositionBudgetEffect({
118
+ ticks: makeTicks(),
119
+ position: makePosition(),
120
+ userMaxSy: 700n,
121
+ userMaxPt: 500n,
122
+ }),
123
+ ).toMatchObject({
124
+ effectiveMaxSy: 0n,
125
+ effectiveMaxPt: 0n,
126
+ fixedSySpent: 700n,
127
+ fixedPtSpent: 500n,
128
+ })
129
+ })
130
+
131
+ it("expands desired effective budgets to user-facing instruction inputs", () => {
132
+ expect(
133
+ computeRequiredUserMaxForExistingPositionEqualization({
134
+ ticks: makeTicks(),
135
+ position: makePosition(),
136
+ desiredEffectiveMaxSy: 10n,
137
+ desiredEffectiveMaxPt: 20n,
138
+ }),
139
+ ).toMatchObject({
140
+ userMaxSy: 710n,
141
+ userMaxPt: 520n,
142
+ })
143
+ })
144
+
145
+ it("returns a no-op for inactive crossing splits", () => {
146
+ const position = makePosition()
147
+ position.crossingSplit = {
148
+ crossLeftIdx: SENTINEL_TICK_INDEX,
149
+ crossRightIdx: SENTINEL_TICK_INDEX,
150
+ lpBalanceCrossing: 0n,
151
+ isActive: false,
152
+ }
153
+
154
+ expect(computeExistingPositionEqualization(makeTicks(), position)).toMatchObject({
155
+ sySpent: 0n,
156
+ ptSpent: 0n,
157
+ syReleased: 0n,
158
+ ptReleased: 0n,
159
+ plan: [],
160
+ })
161
+ })
162
+ })