@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.
@@ -396,32 +396,32 @@ export const getSwapOutput = (
396
396
  };
397
397
 
398
398
  /**
399
- * Calculates the current price (exchange rate) of asset A in terms of asset B for a Stableswaps pool.
400
- * The price is derived from the Stableswaps curve equation and takes into account the amplification factor.
399
+ * The pool's marginal price as raw units of asset A per raw unit of asset B,
400
+ * |dx/dy| at the current reserves. This is the "ideal" price that swap price
401
+ * impact is measured against.
401
402
  *
402
- * The calculation uses the formula:
403
+ * Derived from the Stableswaps invariant 4A(x + y) + D = 4AD + D^3 / (4xy):
403
404
  * price = (xpA + (dR * aReserve) / bReserve) / (xpA + dR)
404
- * where xpA = (Ann * aReserve) / A_PRECISION and dR is derived from the sum invariant
405
+ * where xpA = 4 * laf * aReserve and dR = D^3 / (4 * aReserve * bReserve)
405
406
  *
406
407
  * @param {bigint} aReserve - The current reserve amount of asset A in the pool.
407
408
  * @param {bigint} bReserve - The current reserve amount of asset B in the pool.
408
409
  * @param {bigint} laf - The linear amplification factor of the pool.
409
- * @returns {Fraction} The price of asset A in terms of asset B as a Fraction.
410
+ * @returns {Fraction} Raw A per raw B at the current reserves.
410
411
  */
411
412
  export function getPrice(
412
413
  aReserve: bigint,
413
414
  bReserve: bigint,
414
415
  laf: bigint,
415
416
  ): Fraction {
416
- // dx_0 / dx_1 only, however can have any number of coins in pool
417
- const ann = laf * 2n * A_PRECISION;
418
417
  const sumInvariant =
419
418
  getSumInvariant(laf, aReserve, bReserve) / RESERVE_PRECISION;
420
419
  // D / n^n with n = 2
421
420
  let dR = sumInvariant / 4n;
422
421
  dR = (dR * sumInvariant) / aReserve;
423
422
  dR = (dR * sumInvariant) / bReserve;
424
- const xpA = (ann * aReserve) / A_PRECISION;
423
+ // 4A is the amplification term of the on-chain invariant, A = laf.
424
+ const xpA = 4n * laf * aReserve;
425
425
  const p = new Fraction(xpA + (dR * aReserve) / bReserve, xpA + dR);
426
426
  return p;
427
427
  }
@@ -1,225 +1,218 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { IAssetAmountMetadata } from "@sundaeswap/asset";
3
- import { Fraction } from "@sundaeswap/fraction";
4
3
 
5
4
  import {
6
- calculateLiquidity,
7
- getSwapInput,
5
+ aSat,
6
+ bandOf,
7
+ bandOutput,
8
+ bandProof,
9
+ bandStates,
10
+ bandView,
11
+ buildIndex,
12
+ findWitness,
8
13
  getSwapOutput,
9
- type TSqrtPrice,
14
+ indexLadder,
15
+ liquidityOf,
16
+ prefixes,
17
+ reservesAt,
18
+ shapedLadder,
19
+ swapSteps,
20
+ type TLadder,
21
+ uniformLadder,
10
22
  } from "../ConcentratedLiquidityPool.js";
11
23
 
12
- const tokenA: IAssetAmountMetadata = {
13
- assetId: "09169bb6f5ff5b246d65d65935b2222cc53b5e677d7ed22771878972.744f4b454e41",
24
+ const tokenB: IAssetAmountMetadata = {
25
+ assetId: "09169bb6f5ff5b246d65d65935b2222cc53b5e677d7ed22771878972.744f4b454e42",
14
26
  decimals: 0,
15
27
  };
16
28
 
17
- const threePct = new Fraction(3n, 1000n);
18
- const zeroFee = new Fraction(0n, 1n);
19
-
20
- // Range [0.5, 2.0] as sqrt-price rationals.
21
- const spa: TSqrtPrice = [1n, 2n];
22
- const spb: TSqrtPrice = [2n, 1n];
23
-
24
- /** Direct evaluation of the on-chain/scooper virtual-reserve swap formula. */
25
- const refOutput = (
26
- dx: bigint,
27
- a: bigint,
28
- b: bigint,
29
- l: bigint,
30
- isAInput: boolean,
31
- feeNum: bigint,
32
- feeDen: bigint,
33
- ): bigint => {
34
- const fee = (dx * feeNum) / feeDen;
35
- const dxEff = dx - fee;
36
- const va0 = a * spb[0] + l * spb[1];
37
- const vb0 = b * spa[1] + l * spa[0];
38
- if (isAInput) {
39
- const dvaEff = dxEff * spb[0];
40
- return (vb0 * dvaEff) / ((va0 + dvaEff) * spa[1]);
41
- }
42
- const dvbEff = dxEff * spa[0];
43
- return (va0 * dvbEff) / ((vb0 + dvbEff) * spb[0]);
29
+ // The eight equal-weight ladder of sundae-v4's lib/tests/unit/cl_check_unit.ak
30
+ // (edges 1.00..1.08 at 1e6, fee 3/1000).
31
+ const fee = { num: 3n, den: 1000n };
32
+ const den = 1_000_000n;
33
+ const eq8: TLadder = {
34
+ bands: Array.from({ length: 8 }, (_, i) => ({
35
+ start: { num: 1_000_000n + 10_000n * BigInt(i), den },
36
+ weight: 1n,
37
+ curve: 0 as const,
38
+ feeBuy: fee,
39
+ feeSell: fee,
40
+ })),
41
+ closing: { num: 1_080_000n, den },
42
+ weightTotal: 8n,
44
43
  };
44
+ const A = 8_637_368n;
45
+ const B = 624_999n;
45
46
 
46
- describe("ConcentratedLiquidityPool.getSwapOutput", () => {
47
- it("matches the hand-computed virtual-reserve formula (A→B, no fee)", () => {
48
- // a=b=L=1000, range [0.5,2]: va0=vb0=3000, dvaEff=200,
49
- // out = 3000·200 / ((3000+200)·2) = 600000/6400 = 93.75 -> 93.
50
- const { output } = getSwapOutput(
51
- tokenA,
52
- 100n,
53
- 1000n,
54
- 1000n,
55
- 1000n,
56
- spa,
57
- spb,
58
- zeroFee,
59
- true,
60
- );
61
- expect(output).toEqual(93n);
47
+ describe("ConcentratedLiquidityPool: the on-chain vectors", () => {
48
+ it("finds the witness the Aiken test names", () => {
49
+ const w = findWitness(eq8, A, B)!;
50
+ expect(w.x).toBe(999_999_813n);
51
+ expect(w.k).toBe(0);
52
+ const l = indexLadder(eq8);
53
+ const p = prefixes(l, w.x, w.k);
54
+ expect(p.ca).toBe(8_021_634n);
55
+ expect(p.cb).toBe(0n);
56
+ expect(liquidityOf(l, w.x, w.k)).toBe(124_999_976n);
57
+ expect(aSat(l, w.x, w.k)).toBe(1_237_624n);
58
+ expect(bandProof(l, A, B, 999_999_813n, 0)).toBe(true);
59
+ expect(bandProof(l, A, B, 71_428_457n, 7)).toBe(false);
62
60
  });
63
61
 
64
- it("matches the hand-computed formula (B→A, no fee)", () => {
65
- // dvbEff=100, out = 3000·100 / ((3000+100)·2) = 300000/6200 = 48.38 -> 48.
66
- const { output } = getSwapOutput(
67
- tokenA,
68
- 100n,
69
- 1000n,
70
- 1000n,
71
- 1000n,
72
- spa,
73
- spb,
74
- zeroFee,
75
- false,
76
- );
77
- expect(output).toEqual(48n);
62
+ it("finds the post-swap witness", () => {
63
+ expect(findWitness(eq8, 8_636_752n, 625_623n)!.x).toBe(999_999_938n);
78
64
  });
79
65
 
80
- it("agrees with the reference evaluator across sizes and fees", () => {
81
- for (const isAInput of [true, false]) {
82
- for (const dx of [1n, 7n, 100n, 5000n, 250000n]) {
83
- const { output } = getSwapOutput(
84
- tokenA,
85
- dx,
86
- 1_000_000n,
87
- 1_000_000n,
88
- 1_000_000n,
89
- spa,
90
- spb,
91
- threePct,
92
- isAInput,
93
- );
94
- expect(output).toEqual(
95
- refOutput(dx, 1_000_000n, 1_000_000n, 1_000_000n, isAInput, 3n, 1000n),
96
- );
97
- }
98
- }
66
+ // sundae-v4 #228 (SV4-006): the band fee rounds UP, so 624 B pays a fee
67
+ // of ceil(624 * 3 / 1000) = 2 and the pair admits 615 A. With the floored
68
+ // fee of 1 it admitted 616. lib/tests/unit/cl_check_unit.ak.
69
+ it("pays 615 A for 624 B, the band fee rounded up to 2", () => {
70
+ const out = getSwapOutput(tokenB, 624n, A, B, eq8, false);
71
+ expect(out.output).toBe(615n);
72
+ expect(out.lpFee.amount).toBe(2n);
73
+ expect(out.steps.length).toBe(1);
74
+ expect(out.steps[0]!.after.x).toBe(1_000_000_062n);
75
+ expect(findWitness(eq8, A - 615n, B + 624n)!.x).toBe(1_000_000_062n);
99
76
  });
100
77
 
101
- it("caps output at the output reserve", () => {
102
- const { output } = getSwapOutput(
103
- tokenA,
104
- 10_000_000n,
105
- 100n,
106
- 5n,
107
- 1_000_000n,
108
- spa,
109
- spb,
110
- zeroFee,
111
- true,
112
- );
113
- expect(output).toBeLessThanOrEqual(5n);
78
+ it("recovers from a stale hint", () => {
79
+ const out = getSwapOutput(tokenB, 624n, A, B, eq8, false, { x: 123n, k: 5 });
80
+ expect(out.output).toBe(615n);
114
81
  });
115
82
 
116
- it("throws on non-positive input/reserves/liquidity", () => {
117
- expect(() =>
118
- getSwapOutput(tokenA, 0n, 10n, 10n, 10n, spa, spb, zeroFee, true),
119
- ).toThrow();
120
- expect(() =>
121
- getSwapOutput(tokenA, 1n, 10n, 10n, 0n, spa, spb, zeroFee, true),
122
- ).toThrow();
83
+ it("charges a fee of one unit on an input below den / num", () => {
84
+ const l = indexLadder(eq8);
85
+ const v = bandView(l, A, B, findWitness(l, A, B)!);
86
+ // ceil(1 * 3 / 1000) = 1, so one unit of input has no effective input.
87
+ expect(bandOutput(v, false, 1n)).toBe(0n);
88
+ // ceil(333 * 3 / 1000) = 1: dx_eff = 332.
89
+ const free = { ...v, feeBuy: { num: 0n, den: 1n } };
90
+ expect(bandOutput(v, false, 333n)).toBe(bandOutput(free, false, 332n));
91
+ // ceil(1000 * 3 / 1000) = 3 exactly: no extra unit when it divides.
92
+ expect(bandOutput(v, false, 1_000n)).toBe(bandOutput(free, false, 997n));
93
+ // A zero rate still charges nothing, whatever its denominator.
94
+ const zero = { ...v, feeBuy: { num: 0n, den: 1_000n } };
95
+ expect(bandOutput(zero, false, 1_000n)).toBe(bandOutput(free, false, 1_000n));
96
+ });
97
+
98
+ it("crosses bands for an input larger than the active band", () => {
99
+ const l = indexLadder(eq8);
100
+ const w = findWitness(l, A, B)!;
101
+ // Band 0 holds a_sat_0 of A; buying more than that crosses into band 1.
102
+ const steps = swapSteps(l, A, B, false, 2_000_000n, w)!;
103
+ expect(steps.length).toBeGreaterThan(1);
104
+ expect(steps[0]!.band).toBe(0);
105
+ expect(steps[1]!.band).toBe(1);
106
+ const total = steps.reduce((s, st) => s + st.output, 0n);
107
+ expect(total).toBeGreaterThan(1_237_624n);
108
+ expect(total).toBeLessThan(A);
123
109
  });
124
- });
125
110
 
126
- describe("ConcentratedLiquidityPool.getSwapInput", () => {
127
- it("returns the minimal input whose forward swap clears the target", () => {
128
- for (const isAInput of [true, false]) {
129
- for (const targetOut of [1n, 40n, 93n, 1000n, 90_000n]) {
130
- const { input } = getSwapInput(
131
- tokenA,
132
- targetOut,
133
- 1_000_000n,
134
- 1_000_000n,
135
- 1_000_000n,
136
- spa,
137
- spb,
138
- threePct,
139
- isAInput,
140
- );
141
- const got = getSwapOutput(
142
- tokenA,
143
- input,
144
- 1_000_000n,
145
- 1_000_000n,
146
- 1_000_000n,
147
- spa,
148
- spb,
149
- threePct,
150
- isAInput,
151
- ).output;
152
- // Reaches the target...
153
- expect(got).toBeGreaterThanOrEqual(targetOut);
154
- // ...and is minimal: one unit less falls short.
155
- const less = getSwapOutput(
156
- tokenA,
157
- input - 1n,
158
- 1_000_000n,
159
- 1_000_000n,
160
- 1_000_000n,
161
- spa,
162
- spb,
163
- threePct,
164
- isAInput,
165
- ).output;
166
- expect(less).toBeLessThan(targetOut);
167
- }
168
- }
111
+ it("refuses an input the ladder cannot absorb", () => {
112
+ // The price sits in band 0: selling A pays out band 0's B residual and
113
+ // then has no band below to walk into.
114
+ const small = getSwapOutput(tokenB, 1_000n, A, B, eq8, true);
115
+ expect(small.output).toBeGreaterThan(0n);
116
+ expect(() => getSwapOutput(tokenB, 1_000_000_000n, A, B, eq8, true)).toThrow();
169
117
  });
170
118
 
171
- it("throws when the output exceeds the reserve", () => {
172
- expect(() =>
173
- getSwapInput(tokenA, 200n, 1000n, 100n, 1000n, spa, spb, threePct, true),
174
- ).toThrow();
119
+ it("splits the reserves across the bands exactly", () => {
120
+ const states = bandStates(eq8, A, B)!;
121
+ expect(states.length).toBe(8);
122
+ expect(states[0]!.active).toBe(true);
123
+ const sumA = states.reduce((s, b) => s + b.quantities[0], 0n);
124
+ const sumB = states.reduce((s, b) => s + b.quantities[1], 0n);
125
+ expect(sumA).toBe(A);
126
+ expect(sumB).toBe(B);
127
+ for (let i = 1; i < 8; i++) expect(states[i]!.quantities[1]).toBe(0n);
175
128
  });
176
129
  });
177
130
 
178
- describe("ConcentratedLiquidityPool.calculateLiquidity", () => {
179
- // The on-chain CL non-swap invariant a proportional deposit must satisfy.
180
- const invariantHolds = (
181
- a0: bigint,
182
- b0: bigint,
183
- l0: bigint,
184
- da: bigint,
185
- db: bigint,
186
- dl: bigint,
187
- ): boolean => {
188
- const va0 = a0 * spb[0] + l0 * spb[1];
189
- const vb0 = b0 * spa[1] + l0 * spa[0];
190
- const a1 = a0 + da;
191
- const b1 = b0 + db;
192
- const l1 = l0 + dl;
193
- const va1 = a1 * spb[0] + l1 * spb[1];
194
- const vb1 = b1 * spa[1] + l1 * spa[0];
195
- return va1 * vb1 * l0 * l0 >= va0 * vb0 * l1 * l1;
196
- };
197
-
198
- it("mints proportionally and the fill satisfies the CL invariant", () => {
199
- const cases: Array<[bigint, bigint, bigint, bigint, bigint]> = [
200
- // [offeredA, offeredB, reserveA, reserveB, totalLp]
201
- [100n, 100n, 1000n, 1000n, 1000n],
202
- [100n, 250n, 1000n, 1000n, 1000n], // B surplus
203
- [500n, 100n, 3333n, 991n, 1777n], // coprime-ish reserves
204
- [12345n, 67890n, 1_000_000n, 5_000_000n, 2_000_000n],
205
- ];
206
- for (const [a, b, ra, rb, lp] of cases) {
207
- const res = calculateLiquidity(a, b, ra, rb, lp);
208
- expect(res.generatedLp).toBeGreaterThan(0n);
209
- // Never consumes more than offered.
210
- expect(res.actualDepositedA).toBeLessThanOrEqual(a);
211
- expect(res.actualDepositedB).toBeLessThanOrEqual(b);
212
- // The resulting deposit is chain-valid under the CL invariant.
213
- expect(
214
- invariantHolds(
215
- ra,
216
- rb,
217
- lp,
218
- res.actualDepositedA,
219
- res.actualDepositedB,
220
- res.generatedLp,
221
- ),
222
- ).toBe(true);
223
- }
131
+ describe("ConcentratedLiquidityPool: building a ladder", () => {
132
+ it("derives reserves at a launch price that the ladder then witnesses", () => {
133
+ const ladder = shapedLadder(8, { num: 95n, den: 100n }, { num: 105n, den: 100n }, fee, {
134
+ shape: "concentrated",
135
+ });
136
+ expect(ladder.weightTotal).toBe(ladder.bands.reduce((s, b) => s + b.weight, 0n));
137
+ const launch = { num: 1n, den: 1n };
138
+ const k = bandOf(ladder, launch);
139
+ const r = reservesAt(ladder, 1_000_000_000n, k, launch);
140
+ const w = findWitness(ladder, r.A, r.B)!;
141
+ expect(w).not.toBeNull();
142
+ // The reserves are floored, so the band the chain derives can sit one
143
+ // below the one asked for; a creator takes the witness's band.
144
+ expect(Math.abs(w.k - k)).toBeLessThanOrEqual(1);
145
+ expect(buildIndex(ladder).length).toBe(8);
146
+ });
147
+
148
+ it("keeps uniform edges in lowest terms and refuses an inverted range", () => {
149
+ const u = uniformLadder(4, { num: 1n, den: 2n }, { num: 2n, den: 1n }, fee);
150
+ expect(u.bands[0]!.start).toEqual({ num: 1n, den: 2n });
151
+ expect(u.closing).toEqual({ num: 2n, den: 1n });
152
+ expect(() => uniformLadder(4, { num: 2n, den: 1n }, { num: 1n, den: 2n }, fee)).toThrow();
224
153
  });
225
154
  });
155
+
156
+ // The scaling bench of sundae-v4 validators/benches/scaling/cl.ak after #228
157
+ // (the band fee rounded up): for N equal-weight bands with edges
158
+ // 1.00 + 0.01*j and fee 3/1000, a B-in swap of 0.1% of the B reserve.
159
+ // Each row is (N, A, B, X, k, A', B', X', budget); the chain admits A - A'.
160
+ const benchRows: [number, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint][] = [
161
+ [1, 4925865n, 4999999n, 999999826n, 0n, 4920931n, 5004998n, 1000001381n, 1555n],
162
+ [2, 2414759n, 7500000n, 999999999n, 1n, 2407502n, 7507500n, 1000002313n, 2314n],
163
+ [3, 4782629n, 5000000n, 999999899n, 1n, 4777791n, 5005000n, 1000001399n, 1500n],
164
+ [4, 3517823n, 6249999n, 999999799n, 2n, 3511894n, 6256248n, 1000001620n, 1821n],
165
+ [5, 4645760n, 4999999n, 999999749n, 2n, 4641017n, 5004998n, 1000001269n, 1520n],
166
+ [6, 3797891n, 5833333n, 999999799n, 3n, 3792463n, 5839166n, 1000001701n, 1902n],
167
+ [7, 4514876n, 5000000n, 999999766n, 3n, 4510223n, 5005000n, 1000001333n, 1567n],
168
+ [8, 3876484n, 5624999n, 999999799n, 4n, 3871350n, 5630623n, 1000001551n, 1752n],
169
+ [9, 4389622n, 4999999n, 999999674n, 4n, 4385059n, 5004998n, 1000001303n, 1629n],
170
+ [10, 3877638n, 5500000n, 999999786n, 5n, 3872713n, 5505500n, 1000001599n, 1813n],
171
+ [11, 4269672n, 5000000n, 999999879n, 5n, 4265194n, 5005000n, 1000001446n, 1567n],
172
+ [12, 3842499n, 5416666n, 999999799n, 6n, 3837740n, 5422082n, 1000001599n, 1800n],
173
+ [13, 4154721n, 4999999n, 999999649n, 6n, 4150328n, 5004998n, 1000001383n, 1734n],
174
+ [14, 3788541n, 5357142n, 999999799n, 7n, 3783921n, 5362499n, 1000001599n, 1800n],
175
+ [15, 4044489n, 5000000n, 999999848n, 7n, 4040176n, 5005000n, 1000001445n, 1597n],
176
+ [16, 3724376n, 5312499n, 999999799n, 8n, 3719878n, 5317811n, 1000001399n, 1600n],
177
+ [17, 3938713n, 4999999n, 999999712n, 8n, 3934480n, 5004998n, 1000001387n, 1675n],
178
+ [18, 3654687n, 5277777n, 999999643n, 9n, 3650300n, 5283054n, 1000001395n, 1752n],
179
+ [19, 3837151n, 5000000n, 999999903n, 9n, 3832994n, 5005000n, 1000001455n, 1552n],
180
+ [20, 3582202n, 5249999n, 999999799n, 10n, 3577917n, 5255248n, 1000001399n, 1600n],
181
+ [21, 3739575n, 4999999n, 999999692n, 10n, 3735494n, 5004998n, 1000001309n, 1617n],
182
+ [22, 3508584n, 5227272n, 999999799n, 11n, 3504393n, 5232499n, 1000001399n, 1600n],
183
+ [23, 3645776n, 5000000n, 999999854n, 11n, 3641767n, 5005000n, 1000001380n, 1526n],
184
+ [24, 3434886n, 5208333n, 999999754n, 12n, 3430785n, 5213541n, 1000001588n, 1834n],
185
+ [25, 3555555n, 5000000n, 999999791n, 12n, 3551617n, 5005000n, 1000001445n, 1654n],
186
+ [26, 3361787n, 5192307n, 999999655n, 13n, 3357770n, 5197499n, 1000001522n, 1867n],
187
+ [27, 3468729n, 4999999n, 999999792n, 13n, 3464861n, 5004998n, 1000001295n, 1503n],
188
+ [28, 3289730n, 5178571n, 999999799n, 14n, 3285794n, 5183749n, 1000001614n, 1815n],
189
+ [29, 3385125n, 4999999n, 999999735n, 14n, 3381324n, 5004998n, 1000001272n, 1537n],
190
+ [30, 3219003n, 5166666n, 999999799n, 15n, 3215144n, 5171832n, 1000001599n, 1800n],
191
+ [31, 3304583n, 4999999n, 999999653n, 15n, 3300848n, 5004998n, 1000001389n, 1736n],
192
+ [32, 3149791n, 5156250n, 999999799n, 16n, 3146005n, 5161406n, 1000001553n, 1754n],
193
+ ];
194
+
195
+ const benchLadder = (n: number): TLadder => ({
196
+ bands: Array.from({ length: n }, (_, i) => ({
197
+ start: { num: 1_000_000n + 10_000n * BigInt(i), den },
198
+ weight: 1n,
199
+ curve: 0 as const,
200
+ feeBuy: fee,
201
+ feeSell: fee,
202
+ })),
203
+ closing: { num: 1_000_000n + 10_000n * BigInt(n), den },
204
+ weightTotal: BigInt(n),
205
+ });
206
+
207
+ describe("ConcentratedLiquidityPool: the scaling bench vectors", () => {
208
+ for (const [n, a, b, x, k, a1, b1] of benchRows) {
209
+ it(`pays ${a - a1} A for ${b1 - b} B on ${n} bands`, () => {
210
+ const ladder = benchLadder(n);
211
+ const w = findWitness(ladder, a, b)!;
212
+ expect(w.x).toBe(x);
213
+ expect(BigInt(w.k)).toBe(k);
214
+ const out = getSwapOutput(tokenB, b1 - b, a, b, ladder, false, w);
215
+ expect(out.output).toBe(a - a1);
216
+ });
217
+ }
218
+ });