@sundaeswap/math 0.5.0 → 0.6.0

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.
@@ -1,12 +1,10 @@
1
1
  import { AssetAmount, IAssetAmountMetadata } from "@sundaeswap/asset";
2
- import { Fraction, type TFractionLike } from "@sundaeswap/fraction";
3
-
4
- import * as ConstantProductPool from "./ConstantProductPool.js";
2
+ import { Fraction } from "@sundaeswap/fraction";
5
3
 
6
4
  /**
7
- * Holds the calculated outcome of a concentrated-liquidity swap. Mirrors
8
- * {@link ConstantProductPool.TSwapOutcome} so it flows through the same generic
9
- * swap-outcome consumers.
5
+ * Holds the calculated outcome of a concentrated-liquidity swap. It has the
6
+ * same shape as `ConstantProductPool.TSwapOutcome`, so the generic
7
+ * swap-outcome consumers accept it.
10
8
  */
11
9
  export type TSwapOutcome = {
12
10
  input: bigint;
@@ -17,259 +15,761 @@ export type TSwapOutcome = {
17
15
  priceImpact: Fraction;
18
16
  };
19
17
 
20
- /** A sqrt-price bound from the CL config, as an exact rational `[num, den]`. */
21
- export type TSqrtPrice = [bigint, bigint];
22
-
23
18
  /**
24
- * Concentrated liquidity is Sundae v4's single-range invariant. Unlike Uniswap
25
- * v3 there are no per-position ranges or ticks: the whole pool shares ONE
26
- * immutable price range `[sqrtPriceA, sqrtPriceB]` (a < b) set at creation, and
27
- * LP is a single fungible token. Swaps run on *virtual* reserves that fold the
28
- * pool's liquidity `L` (its total LP supply) into each side:
19
+ * Concentrated liquidity: Sundae v4's ladder invariant.
20
+ *
21
+ * A pool is a ladder of bands in ascending sqrt-price order. Each band is a
22
+ * concentrated-liquidity arc (`curve` 0) or a constant-sum bin (`curve` 1)
23
+ * with its own share of the pool's liquidity (`weight / weightTotal`) and
24
+ * its own two fees. LP is one fungible token for the whole ladder; a
25
+ * deposit or withdrawal scales every band.
29
26
  *
30
- * VA = a·spbNum + L·spbDen
31
- * VB = b·spaDen + L·spaNum
27
+ * The chain never stores the price. It stores reserves, and the state is a
28
+ * WITNESS `(X, k)`: the ladder counter `X` (the pool's `total_lp`, spec V12)
29
+ * and the active band `k`, which the reserves must prove. Everything here
30
+ * mirrors `lib/modules/cl_check.ak` and the scooper's ladder
31
+ * math, rounding included; where they disagree the chain wins
32
+ * and the transaction fails. The rounding is not negotiable:
32
33
  *
33
- * where `a`/`b` are the raw assetA/assetB reserves. This is a direct port of the
34
- * scooper's `cl_swap_result` (scooper-v2/src/sundaev4/swap_math.rs) and the
35
- * on-chain `cl_check_swap` (sundae-v4/lib/modules/cl_check.ak) — those are the
36
- * source of truth; keep this in lockstep with them.
34
+ * - `L_k` floors; saturation ceils, in ONE division;
35
+ * - the band fee ceils, per step, on the step's real input;
36
+ * - the prefix sums come from the LADDER INDEX (cumulative coefficients
37
+ * in fixed point at 2^64, spec §2.3), multiplied by the counter and
38
+ * ceiled ONCE. A per-band sum of ceilings is a different number and the
39
+ * chain rejects it.
37
40
  */
38
41
 
39
- const asFeeParts = (fee: TFractionLike): [bigint, bigint] => {
40
- const f = Fraction.asFraction(fee);
41
- if (f.lt(Fraction.ZERO) || f.gte(Fraction.ONE))
42
- throw new Error("fee must be in [0, 1)");
43
- return [BigInt(f.numerator), BigInt(f.denominator)];
42
+ /** An exact rational `num / den`. */
43
+ export type TFrac = { num: bigint; den: bigint };
44
+
45
+ /** One band of the ladder. */
46
+ export type TBand = {
47
+ /** The band's LOWER sqrt-price edge. The next band's `start` is its upper edge. */
48
+ start: TFrac;
49
+ /** The band's share of the ladder's liquidity counter. */
50
+ weight: bigint;
51
+ /** 0 = concentrated-liquidity arc, 1 = constant-sum bin. */
52
+ curve: 0 | 1;
53
+ /** Fee on swaps that buy the pool's first asset (B in). */
54
+ feeBuy: TFrac;
55
+ /** Fee on swaps that sell the pool's first asset (A in). */
56
+ feeSell: TFrac;
44
57
  };
45
58
 
59
+ /** A ladder: bands, the top edge, and the sum of the weights. */
60
+ export type TLadder = {
61
+ bands: TBand[];
62
+ /** The last band's UPPER sqrt-price edge. */
63
+ closing: TFrac;
64
+ weightTotal: bigint;
65
+ };
66
+
67
+ /** The witness the chain checks: the ladder counter and the active band. */
68
+ export type TWitness = { x: bigint; k: number };
69
+
70
+ /** 2^64, the fixed-point scale of the ladder index. */
71
+ export const FIXED_ONE = 1n << 64n;
72
+
46
73
  /**
47
- * The raw forward swap: given a pre-fee input `dx`, return the output amount.
48
- * A→B and B→A use different virtual-reserve scalings (see `cl_check_swap`).
49
- * All quantities are non-negative so integer division floors, matching chain.
74
+ * The least counter per unit of weight a ladder may be created with.
75
+ * Mirrors `cl_check.min_counter_per_weight` (sundae-v4 #229, SV4-007):
76
+ * Create requires `total_lp >= MIN_COUNTER_PER_WEIGHT * weightTotal`.
50
77
  */
51
- const rawSwapOutput = (
52
- dx: bigint,
53
- aReserve: bigint,
54
- bReserve: bigint,
55
- totalLp: bigint,
56
- spaNum: bigint,
57
- spaDen: bigint,
58
- spbNum: bigint,
59
- spbDen: bigint,
60
- feeNum: bigint,
61
- feeDen: bigint,
78
+ export const MIN_COUNTER_PER_WEIGHT = 1000n;
79
+
80
+ /** The least counter (initial `total_lp`) the chain accepts at Create for `ladder`. */
81
+ export const minCreateCounter = (ladder: TLadder): bigint =>
82
+ MIN_COUNTER_PER_WEIGHT * ladder.weightTotal;
83
+
84
+ const ceilDiv = (n: bigint, d: bigint): bigint => (n + d - 1n) / d;
85
+
86
+ /** Band i's upper sqrt-price edge. */
87
+ export const upperEdge = (l: TLadder, i: number): TFrac =>
88
+ i + 1 < l.bands.length ? l.bands[i + 1]!.start : l.closing;
89
+
90
+ /** `D_i = hi.num·lo.den − lo.num·hi.den`: positive exactly when the edges increase. */
91
+ export const delta = (lo: TFrac, hi: TFrac): bigint =>
92
+ hi.num * lo.den - lo.num * hi.den;
93
+
94
+ /** `a_sat_i = ceil(w_i·X·D_i / (W·lo.num·hi.num))`: the A a band above the price holds. */
95
+ export const aSat = (l: TLadder, x: bigint, i: number): bigint => {
96
+ const lo = l.bands[i]!.start;
97
+ const hi = upperEdge(l, i);
98
+ return ceilDiv(
99
+ l.bands[i]!.weight * x * delta(lo, hi),
100
+ l.weightTotal * lo.num * hi.num,
101
+ );
102
+ };
103
+
104
+ /** `b_sat_i = ceil(w_i·X·D_i / (W·lo.den·hi.den))`: the B a band below the price holds. */
105
+ export const bSat = (l: TLadder, x: bigint, i: number): bigint => {
106
+ const lo = l.bands[i]!.start;
107
+ const hi = upperEdge(l, i);
108
+ return ceilDiv(
109
+ l.bands[i]!.weight * x * delta(lo, hi),
110
+ l.weightTotal * lo.den * hi.den,
111
+ );
112
+ };
113
+
114
+ /** `L_k = floor(w_k·X / W)`. */
115
+ export const liquidityOf = (l: TLadder, x: bigint, k: number): bigint =>
116
+ (l.bands[k]!.weight * x) / l.weightTotal;
117
+
118
+ /** Band i's A-side saturation coefficient: the part of `a_sat_i` independent of X, rounded up. */
119
+ export const coeffA = (l: TLadder, i: number): bigint => {
120
+ const lo = l.bands[i]!.start;
121
+ const hi = upperEdge(l, i);
122
+ return ceilDiv(
123
+ l.bands[i]!.weight * delta(lo, hi) * FIXED_ONE,
124
+ l.weightTotal * lo.num * hi.num,
125
+ );
126
+ };
127
+
128
+ /** The same on the B side. */
129
+ export const coeffB = (l: TLadder, i: number): bigint => {
130
+ const lo = l.bands[i]!.start;
131
+ const hi = upperEdge(l, i);
132
+ return ceilDiv(
133
+ l.bands[i]!.weight * delta(lo, hi) * FIXED_ONE,
134
+ l.weightTotal * lo.den * hi.den,
135
+ );
136
+ };
137
+
138
+ /** One index entry: the cumulative coefficients above and below band k. */
139
+ export type TIndexEntry = { ca: bigint; cb: bigint };
140
+
141
+ /**
142
+ * The ladder index `Create` pins and every spend reads: entry k is the pair
143
+ * of cumulative coefficients for the bands strictly above and strictly
144
+ * below band k. `cl_check.build_index`, to the digit.
145
+ */
146
+ export const buildIndex = (l: TLadder): TIndexEntry[] => {
147
+ const n = l.bands.length;
148
+ const a = Array.from({ length: n }, (_, i) => coeffA(l, i));
149
+ const b = Array.from({ length: n }, (_, i) => coeffB(l, i));
150
+ const out: TIndexEntry[] = [];
151
+ for (let k = 0; k < n; k++) {
152
+ let ca = 0n;
153
+ let cb = 0n;
154
+ for (let i = 0; i < n; i++) {
155
+ if (i > k) ca += a[i]!;
156
+ if (i < k) cb += b[i]!;
157
+ }
158
+ out.push({ ca, cb });
159
+ }
160
+ return out;
161
+ };
162
+
163
+ /**
164
+ * A ladder with its index built once. Every probe of the witness search
165
+ * reads the index for every band, and rebuilding it per probe is most of
166
+ * the cost of a quote.
167
+ */
168
+ export type TIndexedLadder = TLadder & { index: TIndexEntry[] };
169
+
170
+ export const indexLadder = (l: TLadder): TIndexedLadder =>
171
+ "index" in l && Array.isArray((l as TIndexedLadder).index)
172
+ ? (l as TIndexedLadder)
173
+ : { ...l, index: buildIndex(l) };
174
+
175
+ /** `C_A`, `C_B` at (X, k) from the index: one ceiling after the multiply by X. */
176
+ export const prefixes = (
177
+ l: TIndexedLadder,
178
+ x: bigint,
179
+ k: number,
180
+ ): { ca: bigint; cb: bigint } => {
181
+ const e = l.index[k]!;
182
+ return { ca: ceilDiv(e.ca * x, FIXED_ONE), cb: ceilDiv(e.cb * x, FIXED_ONE) };
183
+ };
184
+
185
+ /** `cl_check.f_at_raw`: the CL arc, cross-multiplied. */
186
+ const fAt = (a: bigint, b: bigint, L: bigint, lo: TFrac, hi: TFrac): bigint =>
187
+ (a * hi.num + L * hi.den) * (b * lo.den + L * lo.num) -
188
+ L * L * hi.num * lo.den;
189
+
190
+ /** G at a witness, on whichever curve the active band carries. */
191
+ const gAt = (
192
+ l: TIndexedLadder,
193
+ A: bigint,
194
+ B: bigint,
195
+ x: bigint,
196
+ k: number,
197
+ ): bigint => {
198
+ const { ca, cb } = prefixes(l, x, k);
199
+ const lo = l.bands[k]!.start;
200
+ const hi = upperEdge(l, k);
201
+ const ra = A - ca;
202
+ const rb = B - cb;
203
+ const L = liquidityOf(l, x, k);
204
+ return l.bands[k]!.curve === 0
205
+ ? fAt(ra, rb, L, lo, hi)
206
+ : ra * (lo.num * hi.num) + rb * (lo.den * hi.den) - L * delta(lo, hi);
207
+ };
208
+
209
+ /** The band proof, P1 through P7. The chain runs exactly this. */
210
+ export const bandProof = (
211
+ l: TIndexedLadder,
212
+ A: bigint,
213
+ B: bigint,
214
+ x: bigint,
215
+ k: number,
216
+ ): boolean => {
217
+ if (x < l.weightTotal) return false; // P1
218
+ const { ca, cb } = prefixes(l, x, k);
219
+ const ra = A - ca;
220
+ const rb = B - cb;
221
+ if (ra < 0n || rb < 0n) return false; // P2, P3
222
+ if (ra > aSat(l, x, k) || rb > bSat(l, x, k)) return false; // P4, P5
223
+ if (gAt(l, A, B, x, k) < 0n) return false; // P6
224
+ return gAt(l, A, B, x + 1n, k) < 0n; // P7
225
+ };
226
+
227
+ const SCAN = 400n;
228
+ const CAP = 1n << 80n;
229
+
230
+ /**
231
+ * The witness in band k, seeded at `hint` when given: the counter only
232
+ * grows along a swap, so the doubling search starts from the previous
233
+ * witness. `lo` is the largest x with non-negative residuals and G ≥ 0,
234
+ * and a witness is such an x whose successor fails G, so the scan walks
235
+ * DOWN from `lo` first. Mirrors the scooper's `find_witness_in_band_from`.
236
+ */
237
+ export const findWitnessInBand = (
238
+ l: TIndexedLadder,
239
+ A: bigint,
240
+ B: bigint,
241
+ k: number,
242
+ hint?: bigint,
243
+ ): TWitness | null => {
244
+ const ok = (x: bigint): boolean => {
245
+ const { ca, cb } = prefixes(l, x, k);
246
+ return A - ca >= 0n && B - cb >= 0n && gAt(l, A, B, x, k) >= 0n;
247
+ };
248
+ const search = (start: bigint): TWitness | null => {
249
+ let lo = start;
250
+ let span = 1n;
251
+ let hi = lo + span;
252
+ while (ok(hi) && hi < CAP) {
253
+ lo = hi;
254
+ span *= 2n;
255
+ hi = lo + span;
256
+ }
257
+ while (lo + 1n < hi) {
258
+ const m = (lo + hi) / 2n;
259
+ if (ok(m)) lo = m;
260
+ else hi = m;
261
+ }
262
+ const from = lo > SCAN ? lo - SCAN : 1n;
263
+ for (let x = lo; x >= from; x--) {
264
+ if (bandProof(l, A, B, x, k)) return { x, k };
265
+ }
266
+ for (let x = lo + 1n; x <= lo + SCAN; x++) {
267
+ if (bandProof(l, A, B, x, k)) return { x, k };
268
+ }
269
+ return null;
270
+ };
271
+ if (hint !== undefined && hint > 0n && ok(hint)) {
272
+ const w = search(hint);
273
+ if (w) return w;
274
+ }
275
+ if (!ok(1n)) return null;
276
+ return search(1n);
277
+ };
278
+
279
+ /**
280
+ * The witness `(X, k)` for a pool's reserves. A hint (the indexed
281
+ * `bandCounter` / `activeBand`) is checked first and seeds the search in
282
+ * its band; a stale hint falls back to every band. Null when the reserves
283
+ * sit on no point of the ladder — the chain would reject them too.
284
+ */
285
+ export const findWitness = (
286
+ ladder: TLadder,
287
+ A: bigint,
288
+ B: bigint,
289
+ hint?: TWitness,
290
+ ): TWitness | null => {
291
+ const l = indexLadder(ladder);
292
+ if (hint && hint.k >= 0 && hint.k < l.bands.length) {
293
+ if (bandProof(l, A, B, hint.x, hint.k)) return hint;
294
+ const w = findWitnessInBand(l, A, B, hint.k, hint.x);
295
+ if (w) return w;
296
+ }
297
+ for (let k = 0; k < l.bands.length; k++) {
298
+ const w = findWitnessInBand(l, A, B, k);
299
+ if (w) return w;
300
+ }
301
+ return null;
302
+ };
303
+
304
+ /** The active band's view at a witness: its residual reserves and liquidity. */
305
+ export type TBandView = {
306
+ ra: bigint;
307
+ rb: bigint;
308
+ l: bigint;
309
+ lo: TFrac;
310
+ hi: TFrac;
311
+ curve: 0 | 1;
312
+ feeBuy: TFrac;
313
+ feeSell: TFrac;
314
+ };
315
+
316
+ export const bandView = (
317
+ l: TIndexedLadder,
318
+ A: bigint,
319
+ B: bigint,
320
+ w: TWitness,
321
+ ): TBandView => {
322
+ const { ca, cb } = prefixes(l, w.x, w.k);
323
+ const band = l.bands[w.k]!;
324
+ return {
325
+ ra: A - ca,
326
+ rb: B - cb,
327
+ l: liquidityOf(l, w.x, w.k),
328
+ lo: band.start,
329
+ hi: upperEdge(l, w.k),
330
+ curve: band.curve,
331
+ feeBuy: band.feeBuy,
332
+ feeSell: band.feeSell,
333
+ };
334
+ };
335
+
336
+ /** The band's fee for a direction: `feeSell` when A is sold, `feeBuy` when A is bought. */
337
+ export const feeFor = (v: TBandView, isAInput: boolean): TFrac =>
338
+ isAInput ? v.feeSell : v.feeBuy;
339
+
340
+ /**
341
+ * The band fee on a step's real input `dx`: `ceil(dx * num / den)`.
342
+ * Mirrors `cl_check.band_fee` (sundae-v4 #228, SV4-006). The chain charges
343
+ * it per transcript step and rounds it UP, so a step below `den / num`
344
+ * units still pays one unit. A zero rate charges nothing.
345
+ */
346
+ export const bandFee = (dx: bigint, fee: TFrac): bigint =>
347
+ (dx * fee.num + fee.den - 1n) / fee.den;
348
+
349
+ /** The output one band pays for `dx` within itself. Mirrors the scooper's `band_output`. */
350
+ export const bandOutput = (
351
+ v: TBandView,
62
352
  isAInput: boolean,
353
+ dx: bigint,
63
354
  ): bigint => {
64
- const fee = (dx * feeNum) / feeDen;
65
- const dxEff = dx - fee;
66
- const va0 = aReserve * spbNum + totalLp * spbDen;
67
- const vb0 = bReserve * spaDen + totalLp * spaNum;
68
- if (isAInput) {
69
- const dvaEff = dxEff * spbNum;
70
- const denom = (va0 + dvaEff) * spaDen;
71
- if (denom <= 0n) return 0n;
72
- return (vb0 * dvaEff) / denom;
355
+ const fee = feeFor(v, isAInput);
356
+ if (fee.den <= 0n) return 0n;
357
+ const dxEff = dx - bandFee(dx, fee);
358
+ if (dxEff <= 0n) return 0n;
359
+ if (v.curve === 0) {
360
+ const va0 = v.ra * v.hi.num + v.l * v.hi.den;
361
+ const vb0 = v.rb * v.lo.den + v.l * v.lo.num;
362
+ if (isAInput) {
363
+ const dva = dxEff * v.hi.num;
364
+ const den = (va0 + dva) * v.lo.den;
365
+ return den === 0n ? 0n : (vb0 * dva) / den;
366
+ }
367
+ const dvb = dxEff * v.lo.den;
368
+ const den = (vb0 + dvb) * v.hi.num;
369
+ return den === 0n ? 0n : (va0 * dvb) / den;
73
370
  }
74
- const dvbEff = dxEff * spaNum;
75
- const denom = (vb0 + dvbEff) * spbNum;
76
- if (denom <= 0n) return 0n;
77
- return (va0 * dvbEff) / denom;
371
+ const pn = v.lo.num * v.hi.num;
372
+ const pd = v.lo.den * v.hi.den;
373
+ return isAInput ? (dxEff * pn) / pd : (dxEff * pd) / pn;
374
+ };
375
+
376
+ /** What the band can pay out in a direction: its residual of the output asset. */
377
+ export const bandCapacity = (v: TBandView, isAInput: boolean): bigint =>
378
+ isAInput ? v.rb : v.ra;
379
+
380
+ /** The largest input whose output stays within the band. */
381
+ export const maxDxInBand = (v: TBandView, isAInput: boolean): bigint => {
382
+ const cap = bandCapacity(v, isAInput);
383
+ if (cap <= 0n) return 0n;
384
+ const fits = (dx: bigint) => bandOutput(v, isAInput, dx) <= cap;
385
+ let hi = 1n;
386
+ for (let guard = 0; fits(hi); guard++) {
387
+ hi *= 2n;
388
+ if (guard > 200) return hi;
389
+ }
390
+ let lo = 0n;
391
+ while (lo + 1n < hi) {
392
+ const m = (lo + hi) / 2n;
393
+ if (fits(m)) lo = m;
394
+ else hi = m;
395
+ }
396
+ return lo;
397
+ };
398
+
399
+ /** The next band a fill enters: selling A walks down the ladder, buying A walks up. */
400
+ export const nextBand = (
401
+ k: number,
402
+ isAInput: boolean,
403
+ n: number,
404
+ ): number | null =>
405
+ isAInput ? (k === 0 ? null : k - 1) : k + 1 >= n ? null : k + 1;
406
+
407
+ /** One band of a fill: what it paid, and the state it left. */
408
+ export type TFillStep = {
409
+ band: number;
410
+ input: bigint;
411
+ output: bigint;
412
+ after: TWitness;
413
+ };
414
+
415
+ /**
416
+ * Price `dx` across as many bands as it takes, as the scooper's
417
+ * `swap_steps` does: fill the active band, cross to the next, repeat.
418
+ * Returns the steps, or null when the ladder cannot absorb `dx` (the
419
+ * direction is exhausted, or the remainder pays nothing).
420
+ */
421
+ export const swapSteps = (
422
+ ladder: TLadder,
423
+ A: bigint,
424
+ B: bigint,
425
+ isAInput: boolean,
426
+ dx: bigint,
427
+ hint?: TWitness,
428
+ ): TFillStep[] | null => {
429
+ const l = indexLadder(ladder);
430
+ const n = l.bands.length;
431
+ let ca = A;
432
+ let cb = B;
433
+ let remaining = dx;
434
+ const steps: TFillStep[] = [];
435
+ let known: TWitness | null = findWitness(l, A, B, hint);
436
+ if (!known) return null;
437
+ let prefer: number | null = null;
438
+ let hintX: bigint | undefined;
439
+ for (let guard = 0; remaining > 0n; guard++) {
440
+ if (guard > 2 * n + 2) return null;
441
+ let before: TWitness | null = known;
442
+ known = null;
443
+ if (!before) {
444
+ if (prefer !== null) before = findWitnessInBand(l, ca, cb, prefer, hintX);
445
+ if (!before) before = findWitness(l, ca, cb);
446
+ if (!before) return null;
447
+ }
448
+ const view = bandView(l, ca, cb, before);
449
+ const capDx = maxDxInBand(view, isAInput);
450
+ const stepDx = capDx < remaining ? capDx : remaining;
451
+ const dy = stepDx > 0n ? bandOutput(view, isAInput, stepDx) : 0n;
452
+ if (dy <= 0n) {
453
+ const kNext = nextBand(before.k, isAInput, n);
454
+ if (kNext === null || prefer === kNext) return null;
455
+ prefer = kNext;
456
+ continue;
457
+ }
458
+ const na = isAInput ? ca + stepDx : ca - dy;
459
+ const nb = isAInput ? cb - dy : cb + stepDx;
460
+ const crossing = stepDx < remaining;
461
+ let after: TWitness | null = null;
462
+ if (crossing) {
463
+ const kNext = nextBand(before.k, isAInput, n);
464
+ if (kNext !== null) after = findWitnessInBand(l, na, nb, kNext, before.x);
465
+ } else {
466
+ after = findWitnessInBand(l, na, nb, before.k, before.x);
467
+ }
468
+ if (!after) after = findWitness(l, na, nb);
469
+ if (!after || after.x < before.x) return null;
470
+ steps.push({ band: before.k, input: stepDx, output: dy, after });
471
+ remaining -= stepDx;
472
+ ca = na;
473
+ cb = nb;
474
+ prefer = after.k;
475
+ hintX = after.x;
476
+ known = after;
477
+ }
478
+ return steps;
78
479
  };
79
480
 
80
481
  /**
81
- * Calculate the swap outcome for a concentrated-liquidity pool.
482
+ * The swap output of a concentrated-liquidity pool for `input` of one asset, crossing
483
+ * bands as needed. `aReserve`/`bReserve` are the pool's reserves in
484
+ * canonical order; `hint` is the indexed `bandCounter` / `activeBand`,
485
+ * which seeds the witness search and may be stale.
82
486
  *
83
- * @param inputMetadata Metadata for the supplied (input) asset — used for the fee AssetAmount.
84
- * @param input The pre-fee amount of the input asset being swapped.
85
- * @param aReserve The pool's raw reserve of asset A.
86
- * @param bReserve The pool's raw reserve of asset B.
87
- * @param totalLp The pool's total LP supply (the virtual liquidity `L`).
88
- * @param sqrtPriceA The lower sqrt-price bound `[num, den]` from the CL config.
89
- * @param sqrtPriceB The upper sqrt-price bound `[num, den]` from the CL config.
90
- * @param fee The liquidity-provider fee (plus protocol fee) as a fraction.
91
- * @param isAInput True when asset A is the supplied asset (A→B swap).
92
- * @returns The swap details in the shared {@link TSwapOutcome} shape.
487
+ * The `lpFee` is the fee the bands kept of the input, summed across the
488
+ * steps. `priceImpact` is against the active band's opening marginal
489
+ * price. Throws when the ladder cannot absorb the input.
93
490
  */
94
491
  export const getSwapOutput = (
95
492
  inputMetadata: IAssetAmountMetadata,
96
493
  input: bigint,
97
494
  aReserve: bigint,
98
495
  bReserve: bigint,
99
- totalLp: bigint,
100
- sqrtPriceA: TSqrtPrice,
101
- sqrtPriceB: TSqrtPrice,
102
- fee: TFractionLike,
496
+ ladder: TLadder,
103
497
  isAInput: boolean,
104
- ): TSwapOutcome => {
105
- if (input <= 0n || aReserve <= 0n || bReserve <= 0n || totalLp <= 0n)
106
- throw new Error("Input, reserves and liquidity must be positive");
107
- const [spaNum, spaDen] = sqrtPriceA;
108
- const [spbNum, spbDen] = sqrtPriceB;
109
- if (spaNum <= 0n || spaDen <= 0n || spbNum <= 0n || spbDen <= 0n)
110
- throw new Error("sqrt prices must be positive");
111
- const [feeNum, feeDen] = asFeeParts(fee);
112
-
498
+ hint?: TWitness,
499
+ ): TSwapOutcome & { steps: TFillStep[] } => {
500
+ if (input <= 0n || aReserve < 0n || bReserve < 0n)
501
+ throw new Error("Input must be positive and reserves non-negative");
502
+ if (ladder.bands.length === 0 || ladder.weightTotal <= 0n)
503
+ throw new Error("The ladder has no bands");
504
+ const l = indexLadder(ladder);
505
+ const steps = swapSteps(l, aReserve, bReserve, isAInput, input, hint);
506
+ if (!steps || steps.length === 0)
507
+ throw new Error("The ladder cannot absorb this input in this direction");
508
+ let output = 0n;
509
+ let lpFee = 0n;
510
+ for (const s of steps) {
511
+ output += s.output;
512
+ const band = l.bands[s.band]!;
513
+ const fee = isAInput ? band.feeSell : band.feeBuy;
514
+ lpFee += bandFee(s.input, fee);
515
+ }
113
516
  const inputReserve = isAInput ? aReserve : bReserve;
114
517
  const outputReserve = isAInput ? bReserve : aReserve;
115
-
116
- let output = rawSwapOutput(
117
- input,
118
- aReserve,
119
- bReserve,
120
- totalLp,
121
- spaNum,
122
- spaDen,
123
- spbNum,
124
- spbDen,
125
- feeNum,
126
- feeDen,
127
- isAInput,
128
- );
129
- // Can't take more than the pool actually holds (virtual reserves can
130
- // otherwise let the formula exceed the real reserve near the boundary).
131
518
  if (output > outputReserve) output = outputReserve;
132
-
133
- const inputLpFee = (input * feeNum) / feeDen;
134
- const nextInputReserve = inputReserve + input;
135
- const nextOutputReserve = outputReserve - output;
136
-
137
- // Price impact vs the marginal (dx→0) rate. Marginal output-per-effective-
138
- // input is vb0·spbNum/(va0·spaDen) for A→B (symmetric for B→A), so the ideal
139
- // input-per-output is its reciprocal. Display-only.
140
- const va0 = aReserve * spbNum + totalLp * spbDen;
141
- const vb0 = bReserve * spaDen + totalLp * spaNum;
519
+ // Price impact against the opening marginal of the first band traded.
520
+ const w0 = findWitness(l, aReserve, bReserve, hint)!;
521
+ const v0 = bandView(l, aReserve, bReserve, w0);
142
522
  let priceImpact = Fraction.ZERO;
143
523
  if (output > 0n) {
144
- const idealPrice = isAInput
145
- ? new Fraction(va0 * spaDen, vb0 * spbNum)
146
- : new Fraction(vb0 * spbNum, va0 * spaDen);
147
- const actualPrice = new Fraction(input - inputLpFee, output);
148
- priceImpact = Fraction.ONE.subtract(idealPrice.divide(actualPrice));
524
+ let ideal: Fraction;
525
+ if (v0.curve === 1) {
526
+ const pn = v0.lo.num * v0.hi.num;
527
+ const pd = v0.lo.den * v0.hi.den;
528
+ ideal = isAInput ? new Fraction(pd, pn) : new Fraction(pn, pd);
529
+ } else {
530
+ const va0 = v0.ra * v0.hi.num + v0.l * v0.hi.den;
531
+ const vb0 = v0.rb * v0.lo.den + v0.l * v0.lo.num;
532
+ ideal = isAInput
533
+ ? new Fraction(va0 * v0.lo.den, vb0 * v0.hi.num)
534
+ : new Fraction(vb0 * v0.hi.num, va0 * v0.lo.den);
535
+ }
536
+ const actual = new Fraction(input - lpFee, output);
537
+ priceImpact = Fraction.ONE.subtract(ideal.divide(actual));
149
538
  }
150
-
151
539
  return {
152
540
  input,
153
541
  output,
154
- lpFee: new AssetAmount(inputLpFee, inputMetadata),
155
- nextInputReserve,
156
- nextOutputReserve,
542
+ lpFee: new AssetAmount(lpFee, inputMetadata),
543
+ nextInputReserve: inputReserve + input,
544
+ nextOutputReserve: outputReserve - output,
157
545
  priceImpact,
546
+ steps,
158
547
  };
159
548
  };
160
549
 
550
+ /** What each band holds at the pool's reserves. */
551
+ export type TBandState = {
552
+ /** The band's reserves in canonical order. The bands sum to the pool's reserves. */
553
+ quantities: [bigint, bigint];
554
+ liquidity: bigint;
555
+ active: boolean;
556
+ };
557
+
161
558
  /**
162
- * Calculate the minimal input required to receive a given output — the inverse
163
- * of {@link getSwapOutput}. The forward swap is linear-fractional in the
164
- * effective input, so it inverts in closed form; a short correction loop
165
- * absorbs the fee floor and integer rounding so the result is the exact minimal
166
- * input whose forward swap yields at least `output`.
559
+ * Every band's state at the pool's reserves: a band below the price holds
560
+ * only B, a band above it only A, the active band some of both. Null when
561
+ * the reserves sit on no point of the ladder.
167
562
  */
168
- export const getSwapInput = (
169
- inputMetadata: IAssetAmountMetadata,
170
- output: bigint,
563
+ export const bandStates = (
564
+ ladder: TLadder,
171
565
  aReserve: bigint,
172
566
  bReserve: bigint,
173
- totalLp: bigint,
174
- sqrtPriceA: TSqrtPrice,
175
- sqrtPriceB: TSqrtPrice,
176
- fee: TFractionLike,
177
- isAInput: boolean,
178
- ): TSwapOutcome => {
179
- if (output <= 0n || aReserve <= 0n || bReserve <= 0n || totalLp <= 0n)
180
- throw new Error("Output, reserves and liquidity must be positive");
181
- const [spaNum, spaDen] = sqrtPriceA;
182
- const [spbNum, spbDen] = sqrtPriceB;
183
- if (spaNum <= 0n || spaDen <= 0n || spbNum <= 0n || spbDen <= 0n)
184
- throw new Error("sqrt prices must be positive");
185
- const outputReserve = isAInput ? bReserve : aReserve;
186
- if (output > outputReserve)
187
- throw new Error("Output must not exceed the output reserve");
188
-
189
- const [feeNum, feeDen] = asFeeParts(fee);
190
- const feeDiff = feeDen - feeNum;
191
-
192
- const va0 = aReserve * spbNum + totalLp * spbDen;
193
- const vb0 = bReserve * spaDen + totalLp * spaNum;
194
-
195
- const ceilDiv = (n: bigint, d: bigint) => (n + d - 1n) / d;
196
-
197
- // Closed-form minimal effective input, then gross up for the fee floor.
198
- let effMin: bigint;
199
- if (isAInput) {
200
- // dvaEff ≥ output·spaDen·va0 / (vb0 − output·spaDen)
201
- const denom = vb0 - output * spaDen;
202
- if (denom <= 0n)
203
- throw new Error("Output not reachable within the pool's range");
204
- const dvaEffMin = ceilDiv(output * spaDen * va0, denom);
205
- effMin = ceilDiv(dvaEffMin, spbNum);
206
- } else {
207
- const denom = va0 - output * spbNum;
208
- if (denom <= 0n)
209
- throw new Error("Output not reachable within the pool's range");
210
- const dvbEffMin = ceilDiv(output * spbNum * vb0, denom);
211
- effMin = ceilDiv(dvbEffMin, spaNum);
567
+ hint?: TWitness,
568
+ ): TBandState[] | null => {
569
+ const l = indexLadder(ladder);
570
+ const w = findWitness(l, aReserve, bReserve, hint);
571
+ if (!w) return null;
572
+ const out: TBandState[] = [];
573
+ let restA = 0n;
574
+ let restB = 0n;
575
+ for (let i = 0; i < l.bands.length; i++) {
576
+ const liquidity = liquidityOf(l, w.x, i);
577
+ if (i < w.k) {
578
+ const b = bSat(l, w.x, i);
579
+ restB += b;
580
+ out.push({ quantities: [0n, b], liquidity, active: false });
581
+ } else if (i > w.k) {
582
+ const a = aSat(l, w.x, i);
583
+ restA += a;
584
+ out.push({ quantities: [a, 0n], liquidity, active: false });
585
+ } else {
586
+ out.push({ quantities: [0n, 0n], liquidity, active: true });
587
+ }
212
588
  }
213
- let input = feeDiff > 0n ? ceilDiv(effMin * feeDen, feeDiff) : effMin;
214
-
215
- // Correct for floor interactions: nudge up until the forward swap clears the
216
- // target, then trim any over-shoot. Bounded — a couple of iterations at most.
217
- const fwd = (dx: bigint) =>
218
- rawSwapOutput(
219
- dx,
220
- aReserve,
221
- bReserve,
222
- totalLp,
223
- spaNum,
224
- spaDen,
225
- spbNum,
226
- spbDen,
227
- feeNum,
228
- feeDen,
229
- isAInput,
230
- );
231
- let guard = 0;
232
- while (fwd(input) < output && guard++ < 64) input += 1n;
233
- while (input > 1n && fwd(input - 1n) >= output) input -= 1n;
234
-
235
- const inputLpFee = (input * feeNum) / feeDen;
236
- const inputReserve = isAInput ? aReserve : bReserve;
237
- const nextInputReserve = inputReserve + input;
238
- const nextOutputReserve = outputReserve - output;
589
+ const ra = aReserve - restA;
590
+ const rb = bReserve - restB;
591
+ out[w.k]!.quantities = [ra < 0n ? 0n : ra, rb < 0n ? 0n : rb];
592
+ return out;
593
+ };
239
594
 
240
- let priceImpact = Fraction.ZERO;
241
- if (output > 0n) {
242
- const idealPrice = isAInput
243
- ? new Fraction(va0 * spaDen, vb0 * spbNum)
244
- : new Fraction(vb0 * spbNum, va0 * spaDen);
245
- const actualPrice = new Fraction(input - inputLpFee, output);
246
- priceImpact = Fraction.ONE.subtract(idealPrice.divide(actualPrice));
595
+ /** Which band holds a sqrt-price. */
596
+ export const bandOf = (l: TLadder, s: TFrac): number => {
597
+ for (let i = 0; i < l.bands.length; i++) {
598
+ const hi = upperEdge(l, i);
599
+ if (s.num * hi.den < hi.num * s.den) return i;
600
+ }
601
+ return l.bands.length - 1;
602
+ };
603
+
604
+ /**
605
+ * The exact reserves a ladder holds at counter `x` with the price at
606
+ * sqrt-price `s`, which must lie in band `k`: what a pool creator deposits
607
+ * for a chosen shape, scale and launch price. The result is floored, so
608
+ * `findWitness` must still be run on it to learn the counter the chain
609
+ * derives — usually `x`, but rounding can move it.
610
+ */
611
+ export const reservesAt = (
612
+ l: TLadder,
613
+ x: bigint,
614
+ k: number,
615
+ s: TFrac,
616
+ ): { A: bigint; B: bigint } => {
617
+ let A = 0n;
618
+ let B = 0n;
619
+ for (let i = 0; i < l.bands.length; i++) {
620
+ if (i > k) {
621
+ A += aSat(l, x, i);
622
+ continue;
623
+ }
624
+ if (i < k) {
625
+ B += bSat(l, x, i);
626
+ continue;
627
+ }
628
+ const lo = l.bands[i]!.start;
629
+ const hi = upperEdge(l, i);
630
+ const L = liquidityOf(l, x, i);
631
+ if (l.bands[i]!.curve === 1) {
632
+ // A constant-sum bin prices at the geometric mean; `s` only says how
633
+ // far through the bin the conversion has gone.
634
+ const K = L * delta(lo, hi);
635
+ const frac = (s.num * lo.den - lo.num * s.den) * hi.den;
636
+ const span = (hi.num * lo.den - lo.num * hi.den) * s.den;
637
+ const rb = (K * frac) / (span * (lo.den * hi.den));
638
+ A += (K - rb * (lo.den * hi.den)) / (lo.num * hi.num);
639
+ B += rb;
640
+ } else {
641
+ // a = L·(1/s − 1/hi), b = L·(s − lo), in exact rationals.
642
+ A += (L * (s.den * hi.num - s.num * hi.den)) / (s.num * hi.num);
643
+ B += (L * (s.num * lo.den - lo.num * s.den)) / (s.den * lo.den);
644
+ }
247
645
  }
646
+ return { A, B };
647
+ };
648
+
649
+ /** A fraction in lowest terms. */
650
+ const reduceFrac = (f: TFrac): TFrac => {
651
+ const gcd = (a: bigint, b: bigint): bigint => (b === 0n ? a : gcd(b, a % b));
652
+ const g = gcd(f.num < 0n ? -f.num : f.num, f.den);
653
+ return g > 1n ? { num: f.num / g, den: f.den / g } : f;
654
+ };
248
655
 
656
+ /**
657
+ * An equal-weight ladder of `n` bands from `lo` to `hi`, every band a CL
658
+ * arc at `fee` unless named in `curves`. The edges are built over a common
659
+ * denominator and reduced: every rational component of a concentrated-liquidity config
660
+ * must stay below 2^64 (spec V9).
661
+ */
662
+ export const uniformLadder = (
663
+ n: number,
664
+ lo: TFrac,
665
+ hi: TFrac,
666
+ fee: TFrac,
667
+ curves: (0 | 1)[] = [],
668
+ ): TLadder => {
669
+ if (n < 1) throw new Error("A ladder needs at least one band");
670
+ if (lo.num * hi.den >= hi.num * lo.den)
671
+ throw new Error("The lower edge must be strictly below the upper edge");
672
+ const span = hi.num * lo.den - lo.num * hi.den;
673
+ const commonDen = lo.den * hi.den * BigInt(n);
674
+ const bands: TBand[] = [];
675
+ for (let i = 0; i < n; i++) {
676
+ bands.push({
677
+ start: reduceFrac({
678
+ num: lo.num * hi.den * BigInt(n) + span * BigInt(i),
679
+ den: commonDen,
680
+ }),
681
+ weight: 1n,
682
+ curve: curves[i] ?? 0,
683
+ feeBuy: fee,
684
+ feeSell: fee,
685
+ });
686
+ }
249
687
  return {
250
- input,
251
- output,
252
- lpFee: new AssetAmount(inputLpFee, inputMetadata),
253
- nextInputReserve,
254
- nextOutputReserve,
255
- priceImpact,
688
+ bands,
689
+ closing: reduceFrac({
690
+ num: lo.num * hi.den * BigInt(n) + span * BigInt(n),
691
+ den: commonDen,
692
+ }),
693
+ weightTotal: BigInt(n),
694
+ };
695
+ };
696
+
697
+ /** Band weight profiles. Every weight is at least 1, as `Create` demands. */
698
+ export type TWeightShape =
699
+ | "flat"
700
+ | "concentrated"
701
+ | "skewed-low"
702
+ | "skewed-high"
703
+ | "bimodal";
704
+
705
+ export const bandWeights = (
706
+ n: number,
707
+ shape: TWeightShape = "concentrated",
708
+ opts: { centre?: number; peak?: bigint; tail?: bigint } = {},
709
+ ): bigint[] => {
710
+ if (shape === "flat") return Array.from({ length: n }, () => 1n);
711
+ const peak = Number(opts.peak ?? 64n);
712
+ const tail = Number(opts.tail ?? 1n);
713
+ const centre = opts.centre ?? (n - 1) / 2;
714
+ const sigma = Math.max(n / 5, 0.75);
715
+ const bell = (i: number, c: number) => Math.exp(-(((i - c) / sigma) ** 2));
716
+ const profile = (i: number): number => {
717
+ switch (shape) {
718
+ case "concentrated":
719
+ return bell(i, centre);
720
+ case "skewed-low":
721
+ return Math.exp(-i / Math.max(n / 3, 1));
722
+ case "skewed-high":
723
+ return Math.exp(-(n - 1 - i) / Math.max(n / 3, 1));
724
+ case "bimodal":
725
+ return Math.max(bell(i, (n - 1) / 4), bell(i, (3 * (n - 1)) / 4));
726
+ default:
727
+ return 1;
728
+ }
256
729
  };
730
+ return Array.from({ length: n }, (_, i) => {
731
+ const w = Math.round(tail + (peak - tail) * profile(i));
732
+ return BigInt(Math.max(1, w));
733
+ });
257
734
  };
258
735
 
259
736
  /**
260
- * Deposit LP estimate for a concentrated-liquidity pool. CL deposits reuse the
261
- * constant-product *proportional* pinning: minting by the scarcest offered
262
- * asset and ceil-pinning the reserve deltas gives, per asset,
263
- * `a1·L0 ≥ a0·L1` and `b1·L0 ≥ b0·L1` — and those two bounds multiply to
264
- * exactly the on-chain CL non-swap invariant `va1·vb1·L0² ≥ va0·vb0·L1²`
265
- * (`cl_check.ak`). So the proportional deposit is always chain-valid; excess of
266
- * either asset is refunded via aChange/bChange. This mirrors the scooper's
267
- * `resolve_cl_deposit` (which likewise reuses the CP pinning).
737
+ * A ladder of `n` bands from `lo` to `hi` with a weight shape, optional
738
+ * constant-sum bins and an optional per-band fee. The edges are
739
+ * `uniformLadder`'s exactly; only the weights, curves and fees differ.
268
740
  */
269
- export const calculateLiquidity = (
270
- a: bigint,
271
- b: bigint,
272
- aReserve: bigint,
273
- bReserve: bigint,
274
- totalLp: bigint,
275
- ) => ConstantProductPool.calculateLiquidity(a, b, aReserve, bReserve, totalLp);
741
+ export const shapedLadder = (
742
+ n: number,
743
+ lo: TFrac,
744
+ hi: TFrac,
745
+ fee: TFrac,
746
+ opts: {
747
+ shape?: TWeightShape;
748
+ centre?: number;
749
+ peak?: bigint;
750
+ tail?: bigint;
751
+ /** Zero-based band indices to price as constant-sum bins. */
752
+ csBands?: number[];
753
+ /** Overrides `fee` per band. */
754
+ feeAt?: (i: number) => TFrac;
755
+ } = {},
756
+ ): TLadder => {
757
+ const base = uniformLadder(n, lo, hi, fee);
758
+ const weights = bandWeights(n, opts.shape ?? "concentrated", opts);
759
+ const cs = new Set(opts.csBands ?? []);
760
+ const bands: TBand[] = base.bands.map((b, i) => {
761
+ const f = opts.feeAt ? opts.feeAt(i) : fee;
762
+ return {
763
+ ...b,
764
+ weight: weights[i]!,
765
+ curve: cs.has(i) ? 1 : 0,
766
+ feeBuy: f,
767
+ feeSell: f,
768
+ };
769
+ });
770
+ return {
771
+ bands,
772
+ closing: base.closing,
773
+ weightTotal: weights.reduce((s, w) => s + w, 0n),
774
+ };
775
+ };