@sundaeswap/math 0.3.0 → 0.5.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.
@@ -0,0 +1,688 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { IAssetAmountMetadata } from "@sundaeswap/asset";
3
+ import { Fraction } from "@sundaeswap/fraction";
4
+
5
+ import {
6
+ CALC_PRECISION,
7
+ calculateLiquidity,
8
+ calculatePinnedDeposit,
9
+ getD,
10
+ getPrice,
11
+ getRawSwap,
12
+ getSwapInput,
13
+ getSwapOutput,
14
+ invariantF,
15
+ invariantG,
16
+ pinnedDepositFromAnchor,
17
+ } from "../V4StableswapPool.js";
18
+
19
+ // These tests cover the v4 stableswap INVARIANT MODULE. They are unrelated to
20
+ // `StableSwapsPool.test.ts`, which covers the v3 stableswap CONTRACT.
21
+
22
+ const tokenB: IAssetAmountMetadata = {
23
+ assetId: "09169bb6f5ff5b246d65d65935b2222cc53b5e677d7ed22771878972.744f4b454e42",
24
+ decimals: 6,
25
+ };
26
+
27
+ const AMP = 200n;
28
+ const thirtyBps = new Fraction(3n, 1000n);
29
+ const twentyFiveBps = new Fraction(25n, 10000n);
30
+ const zeroFee = new Fraction(0n, 1n);
31
+
32
+ const UNIT_D = 2_000_000_000_000_000_000_000n; // D for (1e9, 1e9) at A = 200
33
+
34
+ describe("V4StableswapPool.getD", () => {
35
+ it("matches the reference vector at balanced unit rates", () => {
36
+ expect(getD(AMP, 1_000_000_000n, 1_000_000_000n)).toBe(UNIT_D);
37
+ });
38
+
39
+ it("matches the reference vector after a swap has moved the reserves", () => {
40
+ expect(getD(AMP, 1_001_000_000n, 1_000_000_000n - 999_497n)).toBe(
41
+ 2_000_000_500_507_486_765_233n,
42
+ );
43
+ });
44
+
45
+ it("reproduces the sum invariant the API reports for a live preview pool", () => {
46
+ // Preview pool ac8d4b1b5fcadb3ac0247f7134ca08b061ee41b0c742dd4edf4c6244,
47
+ // fetched from api.preview.sundae.fi: linearAmplificationFactor 200,
48
+ // rates [1000000, 1001000], quantities [10794901263, 10186281970], and
49
+ // sumInvariant 20991348225358657993984399717.
50
+ //
51
+ // This vector is what pins the amplification SCALE. The API serves one
52
+ // amplification field for both the v3 Stableswaps contract and this curve.
53
+ // If the stored value needed rescaling before it entered the v4 curve, D
54
+ // would not land on the API's own figure.
55
+ expect(
56
+ getD(200n, 10_794_901_263n, 10_186_281_970n, 1_000_000n, 1_001_000n),
57
+ ).toBe(20_991_348_225_358_657_993_984_399_717n);
58
+ });
59
+
60
+ it("returns the largest integer that satisfies the liquidity invariant", () => {
61
+ const d = getD(AMP, 1_234_567n, 7_654_321n);
62
+ const x = 1_234_567n * CALC_PRECISION;
63
+ const y = 7_654_321n * CALC_PRECISION;
64
+ expect(invariantF(x, y, AMP, d) <= 0n).toBe(true);
65
+ expect(invariantF(x, y, AMP, d + 1n) > 0n).toBe(true);
66
+ });
67
+
68
+ it("is homogeneous: scaling both reserves scales D", () => {
69
+ expect(getD(AMP, 2_000_000_000n, 2_000_000_000n)).toBe(2n * UNIT_D);
70
+ });
71
+
72
+ it("scales with the rates, and scaling both rates changes no price", () => {
73
+ const a = getD(AMP, 1_000_000n, 2_000_000n, 3n, 5n);
74
+ const b = getD(AMP, 1_000_000n, 2_000_000n, 30n, 50n);
75
+ // Newton floors at each step, so the two agree to within integer dust
76
+ // rather than exactly.
77
+ const drift = b - 10n * a;
78
+ expect(drift >= -8n && drift <= 8n).toBe(true);
79
+ });
80
+
81
+ it("rates move the balance point: [100, 1] balances a 1:100 reserve ratio", () => {
82
+ // Balanced in rated units, so D equals the rated sum exactly.
83
+ const d = getD(AMP, 1_000_000n, 100_000_000n, 100n, 1n);
84
+ expect(d).toBe(200_000_000n * CALC_PRECISION);
85
+ });
86
+
87
+ it("returns zero for an empty pool", () => {
88
+ expect(getD(AMP, 0n, 0n)).toBe(0n);
89
+ // Rates do not rescue it and do not break it: zero times anything is zero.
90
+ expect(getD(AMP, 0n, 0n, 1_000_000n, 1_001_000n)).toBe(0n);
91
+ });
92
+
93
+ it("rejects exactly one empty reserve instead of dividing by zero", () => {
94
+ // The Newton step divides by 4*xs*ys, which is zero when one side is
95
+ // empty. Without the guard this faults natively rather than reporting the
96
+ // bad input. The chain cannot reach the state; a caller can.
97
+ expect(() => getD(AMP, 0n, 1n)).toThrow("exactly one empty reserve");
98
+ expect(() => getD(AMP, 1n, 0n)).toThrow("exactly one empty reserve");
99
+ expect(() => getD(AMP, 0n, 1_000_000_000n, 1_000_000n, 1_001_000n)).toThrow(
100
+ "exactly one empty reserve",
101
+ );
102
+ });
103
+
104
+ it("rejects a negative reserve before anything else", () => {
105
+ expect(() => getD(AMP, -1n, 1_000n)).toThrow("reserves must be non-negative");
106
+ });
107
+
108
+ it("rejects a non-positive amplification", () => {
109
+ expect(() => getD(0n, 1_000n, 1_000n)).toThrow("amplification must be positive");
110
+ });
111
+
112
+ it("rejects a non-positive rate", () => {
113
+ expect(() => getD(AMP, 1_000n, 1_000n, 0n, 1n)).toThrow("rates must be positive");
114
+ });
115
+ });
116
+
117
+ describe("V4StableswapPool.getRawSwap", () => {
118
+ it("matches the reference vector", () => {
119
+ expect(getRawSwap(AMP, UNIT_D, 1_010_000_000n, 1_000_000_000n)).toBe(
120
+ 9_999_750_604_846_068_206n,
121
+ );
122
+ });
123
+
124
+ it("returns the largest output the exchange invariant admits", () => {
125
+ // Stated on the reserve the pool keeps: `y` is the SMALLEST value with
126
+ // g >= 0, and raw = Y - y, so raw is the LARGEST admissible output. The
127
+ // second assertion is the one that pins the direction — one unit more
128
+ // output puts the pool below the curve.
129
+ const Y = 1_000_000_000n * CALC_PRECISION;
130
+ const raw = getRawSwap(AMP, UNIT_D, 1_010_000_000n, 1_000_000_000n);
131
+ const xs = 1_010_000_000n * CALC_PRECISION;
132
+ expect(invariantG(xs, Y - raw, AMP, UNIT_D) >= 0n).toBe(true);
133
+ expect(invariantG(xs, Y - (raw + 1n), AMP, UNIT_D) < 0n).toBe(true);
134
+ });
135
+
136
+ it("rejects a non-positive sum invariant", () => {
137
+ expect(() => getRawSwap(AMP, 0n, 1_000n, 1_000n)).toThrow(
138
+ "the sum invariant must be positive",
139
+ );
140
+ });
141
+ });
142
+
143
+ describe("V4StableswapPool.getSwapOutput", () => {
144
+ it("reproduces the design-doc worked example (A = 200, 0.3%, balanced 1e9 pool)", () => {
145
+ const { output, lpFee } = getSwapOutput(
146
+ tokenB,
147
+ 10_000_000n,
148
+ 1_000_000_000n,
149
+ 1_000_000_000n,
150
+ 1n,
151
+ 1n,
152
+ AMP,
153
+ thirtyBps,
154
+ );
155
+ // Gross 9 999 750, fee 30 000, trader receives 9 969 750.
156
+ expect(lpFee.amount).toBe(30_000n);
157
+ expect(output).toBe(9_969_750n);
158
+ });
159
+
160
+ it("reproduces a real preview fill", () => {
161
+ // 10 000 000 in at fee 25/10000 on reserves 10 794 901 263 / 10 186 281 970
162
+ // with rates [1 000 000, 1 001 000]: gross 9 988 563, fee 24 972,
163
+ // payout 9 963 591.
164
+ const { output, lpFee, nextInputReserve, nextOutputReserve } = getSwapOutput(
165
+ tokenB,
166
+ 10_000_000n,
167
+ 10_794_901_263n,
168
+ 10_186_281_970n,
169
+ 1_000_000n,
170
+ 1_001_000n,
171
+ AMP,
172
+ twentyFiveBps,
173
+ );
174
+ expect(output + lpFee.amount).toBe(9_988_563n);
175
+ expect(lpFee.amount).toBe(24_972n);
176
+ expect(output).toBe(9_963_591n);
177
+ expect(nextInputReserve).toBe(10_804_901_263n);
178
+ expect(nextOutputReserve).toBe(10_186_281_970n - 9_963_591n);
179
+ });
180
+
181
+ it("denominates the fee in the output asset", () => {
182
+ const { lpFee } = getSwapOutput(
183
+ tokenB,
184
+ 10_000_000n,
185
+ 1_000_000_000n,
186
+ 1_000_000_000n,
187
+ 1n,
188
+ 1n,
189
+ AMP,
190
+ thirtyBps,
191
+ );
192
+ expect(lpFee.metadata.assetId).toBe(tokenB.assetId);
193
+ });
194
+
195
+ it("charges nothing at a zero fee", () => {
196
+ const { output, lpFee } = getSwapOutput(
197
+ tokenB,
198
+ 10_000_000n,
199
+ 1_000_000_000n,
200
+ 1_000_000_000n,
201
+ 1n,
202
+ 1n,
203
+ AMP,
204
+ zeroFee,
205
+ );
206
+ expect(lpFee.amount).toBe(0n);
207
+ expect(output).toBe(9_999_750n);
208
+ });
209
+
210
+ it("prices at par, less one unit of rounding, for a small trade on a balanced pool", () => {
211
+ // `raw` is the most the curve allows, but it carries the 10^12 scale and
212
+ // `gross` floors it to whole tokens. That floor is what costs the trader a
213
+ // unit here, on a fee-free trade that would otherwise land on par.
214
+ const { output } = getSwapOutput(
215
+ tokenB,
216
+ 1_000n,
217
+ 1_000_000_000n,
218
+ 1_000_000_000n,
219
+ 1n,
220
+ 1n,
221
+ AMP,
222
+ zeroFee,
223
+ );
224
+ expect(output).toBe(999n);
225
+ });
226
+
227
+ it("slips more as the pool leaves balance", () => {
228
+ const balanced = getSwapOutput(
229
+ tokenB,
230
+ 100_000_000n,
231
+ 1_000_000_000n,
232
+ 1_000_000_000n,
233
+ 1n,
234
+ 1n,
235
+ AMP,
236
+ zeroFee,
237
+ );
238
+ const skewed = getSwapOutput(
239
+ tokenB,
240
+ 100_000_000n,
241
+ 1_800_000_000n,
242
+ 200_000_000n,
243
+ 1n,
244
+ 1n,
245
+ AMP,
246
+ zeroFee,
247
+ );
248
+ expect(skewed.output < balanced.output).toBe(true);
249
+ expect(skewed.priceImpact.toNumber()).toBeGreaterThan(
250
+ balanced.priceImpact.toNumber(),
251
+ );
252
+ });
253
+
254
+ it("reports a small price impact for a small trade on a balanced pool", () => {
255
+ // 1% of the pool moves the price by about 2.5 basis points at A = 200.
256
+ const { priceImpact } = getSwapOutput(
257
+ tokenB,
258
+ 10_000_000n,
259
+ 1_000_000_000n,
260
+ 1_000_000_000n,
261
+ 1n,
262
+ 1n,
263
+ AMP,
264
+ zeroFee,
265
+ );
266
+ expect(priceImpact.toNumber()).toBeGreaterThan(0);
267
+ expect(priceImpact.toNumber()).toBeLessThan(0.001);
268
+ });
269
+
270
+ it("honours the rates: a 1:100 rated pair trades at 100:1 in token units", () => {
271
+ const { output } = getSwapOutput(
272
+ tokenB,
273
+ 1_000n,
274
+ 1_000_000n,
275
+ 100_000_000n,
276
+ 100n,
277
+ 1n,
278
+ AMP,
279
+ zeroFee,
280
+ );
281
+ // Par is 100 000; the invariant rounds one unit against the trader.
282
+ expect(output).toBe(99_999n);
283
+ });
284
+
285
+ it("rejects a supplied amount of zero", () => {
286
+ expect(() =>
287
+ getSwapOutput(tokenB, 0n, 1_000n, 1_000n, 1n, 1n, AMP, zeroFee),
288
+ ).toThrow("Input and reserves must be positive");
289
+ });
290
+
291
+ it("rejects an empty reserve", () => {
292
+ expect(() =>
293
+ getSwapOutput(tokenB, 100n, 0n, 1_000n, 1n, 1n, AMP, zeroFee),
294
+ ).toThrow("Input and reserves must be positive");
295
+ });
296
+
297
+ it("rejects a non-positive rate", () => {
298
+ expect(() =>
299
+ getSwapOutput(tokenB, 100n, 1_000n, 1_000n, 0n, 1n, AMP, zeroFee),
300
+ ).toThrow("Rates must be positive");
301
+ });
302
+
303
+ it("rejects a non-positive amplification", () => {
304
+ expect(() =>
305
+ getSwapOutput(tokenB, 100n, 1_000n, 1_000n, 1n, 1n, 0n, zeroFee),
306
+ ).toThrow("Amplification must be positive");
307
+ });
308
+
309
+ it("rejects a fee at or above one", () => {
310
+ expect(() =>
311
+ getSwapOutput(
312
+ tokenB,
313
+ 100n,
314
+ 1_000n,
315
+ 1_000n,
316
+ 1n,
317
+ 1n,
318
+ AMP,
319
+ new Fraction(1n, 1n),
320
+ ),
321
+ ).toThrow("fee must be in [0, 1)");
322
+ });
323
+
324
+ it("rejects a trade the fee wipes out", () => {
325
+ expect(() =>
326
+ getSwapOutput(
327
+ tokenB,
328
+ 1n,
329
+ 1_000_000_000n,
330
+ 1_000_000_000n,
331
+ 1n,
332
+ 1n,
333
+ AMP,
334
+ new Fraction(999n, 1000n),
335
+ ),
336
+ ).toThrow("Swap yields no output after the fee");
337
+ });
338
+ });
339
+
340
+ describe("V4StableswapPool.getSwapInput", () => {
341
+ const cases: Array<[string, bigint, bigint, bigint, bigint, bigint, Fraction]> =
342
+ [
343
+ ["balanced, no fee", 1_000_000_000n, 1_000_000_000n, 1n, 1n, 5_000_000n, zeroFee],
344
+ ["balanced, 30 bps", 1_000_000_000n, 1_000_000_000n, 1n, 1n, 5_000_000n, thirtyBps],
345
+ ["skewed, 30 bps", 1_800_000_000n, 200_000_000n, 1n, 1n, 1_000_000n, thirtyBps],
346
+ [
347
+ "rated, 25 bps",
348
+ 10_794_901_263n,
349
+ 10_186_281_970n,
350
+ 1_000_000n,
351
+ 1_001_000n,
352
+ 9_963_591n,
353
+ twentyFiveBps,
354
+ ],
355
+ ["decimal-skewed rates", 1_000_000n, 100_000_000n, 100n, 1n, 500_000n, thirtyBps],
356
+ ];
357
+
358
+ for (const [name, inRes, outRes, rIn, rOut, want, fee] of cases) {
359
+ it(`returns the minimal input that buys the output (${name})`, () => {
360
+ const { input } = getSwapInput(
361
+ tokenB,
362
+ want,
363
+ inRes,
364
+ outRes,
365
+ rIn,
366
+ rOut,
367
+ AMP,
368
+ fee,
369
+ );
370
+ // The forward swap at that input reaches the target.
371
+ expect(
372
+ getSwapOutput(tokenB, input, inRes, outRes, rIn, rOut, AMP, fee).output >=
373
+ want,
374
+ ).toBe(true);
375
+ // One unit less does not.
376
+ expect(
377
+ getSwapOutput(tokenB, input - 1n, inRes, outRes, rIn, rOut, AMP, fee)
378
+ .output < want,
379
+ ).toBe(true);
380
+ });
381
+ }
382
+
383
+ it("round-trips the real preview fill to at most the original input", () => {
384
+ const { input } = getSwapInput(
385
+ tokenB,
386
+ 9_963_591n,
387
+ 10_794_901_263n,
388
+ 10_186_281_970n,
389
+ 1_000_000n,
390
+ 1_001_000n,
391
+ AMP,
392
+ twentyFiveBps,
393
+ );
394
+ expect(input).toBeLessThanOrEqual(10_000_000n);
395
+ expect(input).toBeGreaterThan(9_900_000n);
396
+ });
397
+
398
+ it("denominates the fee in the output asset", () => {
399
+ const { lpFee } = getSwapInput(
400
+ tokenB,
401
+ 1_000_000n,
402
+ 1_000_000_000n,
403
+ 1_000_000_000n,
404
+ 1n,
405
+ 1n,
406
+ AMP,
407
+ thirtyBps,
408
+ );
409
+ expect(lpFee.metadata.assetId).toBe(tokenB.assetId);
410
+ });
411
+
412
+ it("reports the reserves after the fill", () => {
413
+ const { input, nextInputReserve, nextOutputReserve } = getSwapInput(
414
+ tokenB,
415
+ 1_000_000n,
416
+ 1_000_000_000n,
417
+ 1_000_000_000n,
418
+ 1n,
419
+ 1n,
420
+ AMP,
421
+ thirtyBps,
422
+ );
423
+ expect(nextInputReserve).toBe(1_000_000_000n + input);
424
+ expect(nextOutputReserve).toBe(1_000_000_000n - 1_000_000n);
425
+ });
426
+
427
+ it("rejects an output at or above the output reserve", () => {
428
+ expect(() =>
429
+ getSwapInput(
430
+ tokenB,
431
+ 1_000_000_000n,
432
+ 1_000_000_000n,
433
+ 1_000_000_000n,
434
+ 1n,
435
+ 1n,
436
+ AMP,
437
+ zeroFee,
438
+ ),
439
+ ).toThrow("Output must be below the output reserve");
440
+ });
441
+
442
+ it("rejects an output the fee pushes past the reserve", () => {
443
+ expect(() =>
444
+ getSwapInput(
445
+ tokenB,
446
+ 999_999_999n,
447
+ 1_000_000_000n,
448
+ 1_000_000_000n,
449
+ 1n,
450
+ 1n,
451
+ AMP,
452
+ thirtyBps,
453
+ ),
454
+ ).toThrow("the fee pushes it past the reserve");
455
+ });
456
+
457
+ it("rejects a non-positive output", () => {
458
+ expect(() =>
459
+ getSwapInput(tokenB, 0n, 1_000n, 1_000n, 1n, 1n, AMP, zeroFee),
460
+ ).toThrow("Output and reserves must be positive");
461
+ });
462
+ });
463
+
464
+ describe("V4StableswapPool.getPrice", () => {
465
+ it("is the rate ratio at a balanced pool", () => {
466
+ const p = getPrice(AMP, 1_000_000_000n, 1_000_000_000n);
467
+ expect(p.toNumber()).toBeCloseTo(1, 12);
468
+ });
469
+
470
+ it("follows the rates: asset B worth 1.001 of asset A", () => {
471
+ const p = getPrice(AMP, 1_001_000_000n, 1_000_000_000n, 1_000_000n, 1_001_000n);
472
+ expect(p.toNumber()).toBeCloseTo(1.001, 6);
473
+ });
474
+
475
+ it("moves with the reserve ratio, but far less than a constant-product pool would", () => {
476
+ // Asset A is abundant here, so asset B costs more than one A. A constant-
477
+ // product pool at 1.8e9 / 0.2e9 would price B at 9 A; the stableswap curve
478
+ // at A = 200 prices it at about 1.03 A.
479
+ const p = getPrice(AMP, 1_800_000_000n, 200_000_000n);
480
+ expect(p.toNumber()).toBeGreaterThan(1);
481
+ expect(p.toNumber()).toBeLessThan(1.1);
482
+ });
483
+
484
+ it("agrees with the rate a small swap actually gets", () => {
485
+ const p = getPrice(AMP, 1_800_000_000n, 200_000_000n).toNumber();
486
+ // Give B, take A: the realized A-per-B rate should sit just under the
487
+ // marginal price.
488
+ const { output } = getSwapOutput(
489
+ tokenB,
490
+ 1_000_000n,
491
+ 200_000_000n,
492
+ 1_800_000_000n,
493
+ 1n,
494
+ 1n,
495
+ AMP,
496
+ zeroFee,
497
+ );
498
+ const realized = Number(output) / 1_000_000;
499
+ expect(realized).toBeLessThanOrEqual(p);
500
+ expect(p - realized).toBeLessThan(0.001);
501
+ });
502
+
503
+ it("rejects an empty reserve", () => {
504
+ expect(() => getPrice(AMP, 0n, 1_000n)).toThrow("reserves must be positive");
505
+ });
506
+ });
507
+
508
+ describe("V4StableswapPool.calculateLiquidity", () => {
509
+ it("pins the deposit to the scarcest offered asset and refunds the surplus", () => {
510
+ const res = calculateLiquidity(
511
+ 100_000_000n,
512
+ 50_000_000n,
513
+ 1_000_000_000n,
514
+ 1_000_000_000n,
515
+ 2_000_000_000n,
516
+ 1n,
517
+ 1n,
518
+ AMP,
519
+ );
520
+ // The pool is balanced, so the 50 000 000 leg caps the fill.
521
+ expect(res.actualDepositedB).toBe(50_000_000n);
522
+ expect(res.actualDepositedA).toBe(50_000_000n);
523
+ expect(res.bChange).toBe(0n);
524
+ expect(res.aChange).toBe(50_000_000n);
525
+ expect(res.generatedLp).toBe(100_000_000n);
526
+ expect(res.nextTotalLp).toBe(2_100_000_000n);
527
+ });
528
+
529
+ it("mints proportionally for an exactly proportional offer", () => {
530
+ const res = calculateLiquidity(
531
+ 100_000_000n,
532
+ 100_000_000n,
533
+ 1_000_000_000n,
534
+ 1_000_000_000n,
535
+ 2_000_000_000n,
536
+ 1n,
537
+ 1n,
538
+ AMP,
539
+ );
540
+ expect(res.aChange).toBe(0n);
541
+ expect(res.bChange).toBe(0n);
542
+ expect(res.generatedLp).toBe(200_000_000n);
543
+ expect(res.shareAfterDeposit.toNumber()).toBeCloseTo(200 / 2200, 9);
544
+ });
545
+
546
+ it("keeps the deltas proportional when the reserves are not balanced", () => {
547
+ const res = calculateLiquidity(
548
+ 180_000_000n,
549
+ 20_000_000n,
550
+ 1_800_000_000n,
551
+ 200_000_000n,
552
+ 1_000_000_000n,
553
+ 1n,
554
+ 1n,
555
+ AMP,
556
+ );
557
+ expect(res.actualDepositedA).toBe(180_000_000n);
558
+ expect(res.actualDepositedB).toBe(20_000_000n);
559
+ expect(res.aChange).toBe(0n);
560
+ expect(res.bChange).toBe(0n);
561
+ });
562
+
563
+ it("ignores the rates in the deltas: a proportional move is proportional in any units", () => {
564
+ const unrated = calculateLiquidity(
565
+ 100_000_000n,
566
+ 100_000_000n,
567
+ 1_000_000_000n,
568
+ 1_000_000_000n,
569
+ 2_000_000_000n,
570
+ 1n,
571
+ 1n,
572
+ AMP,
573
+ );
574
+ const rated = calculateLiquidity(
575
+ 100_000_000n,
576
+ 100_000_000n,
577
+ 1_000_000_000n,
578
+ 1_000_000_000n,
579
+ 2_000_000_000n,
580
+ 1_000_000n,
581
+ 1_001_000n,
582
+ AMP,
583
+ );
584
+ expect(rated.actualDepositedA).toBe(unrated.actualDepositedA);
585
+ expect(rated.actualDepositedB).toBe(unrated.actualDepositedB);
586
+ });
587
+
588
+ it("never pins a delta above what the depositor offered", () => {
589
+ const res = calculateLiquidity(
590
+ 7_777_777n,
591
+ 3_333_333n,
592
+ 1_234_567_891n,
593
+ 987_654_321n,
594
+ 1_000_000_000n,
595
+ 1n,
596
+ 1n,
597
+ AMP,
598
+ );
599
+ expect(res.actualDepositedA).toBeLessThanOrEqual(7_777_777n);
600
+ expect(res.actualDepositedB).toBeLessThanOrEqual(3_333_333n);
601
+ expect(res.aChange).toBeGreaterThanOrEqual(0n);
602
+ expect(res.bChange).toBeGreaterThanOrEqual(0n);
603
+ });
604
+
605
+ it("rejects a one-sided deposit", () => {
606
+ expect(() =>
607
+ calculateLiquidity(
608
+ 100_000_000n,
609
+ 0n,
610
+ 1_000_000_000n,
611
+ 1_000_000_000n,
612
+ 2_000_000_000n,
613
+ 1n,
614
+ 1n,
615
+ AMP,
616
+ ),
617
+ ).toThrow("must offer every pool asset in proportion");
618
+ });
619
+
620
+ it("rejects a deposit that mints no LP", () => {
621
+ expect(() =>
622
+ calculateLiquidity(
623
+ 1n,
624
+ 1n,
625
+ 1_000_000_000n,
626
+ 1_000_000_000n,
627
+ 2n,
628
+ 1n,
629
+ 1n,
630
+ AMP,
631
+ ),
632
+ ).toThrow("Deposit mints zero LP");
633
+ });
634
+
635
+ it("rejects an empty pool", () => {
636
+ expect(() =>
637
+ calculateLiquidity(1n, 1n, 0n, 0n, 0n, 1n, 1n, AMP),
638
+ ).toThrow("Not enough pool liquidity");
639
+ });
640
+ });
641
+
642
+ describe("V4StableswapPool.calculatePinnedDeposit", () => {
643
+ it("pins every quantity to the declared D delta", () => {
644
+ const d = getD(AMP, 1_000_000_000n, 1_000_000_000n);
645
+ const res = calculatePinnedDeposit(
646
+ [100_000_000n, 100_000_000n],
647
+ [1_000_000_000n, 1_000_000_000n],
648
+ 2_000_000_000n,
649
+ d,
650
+ );
651
+ expect(res.targetDeltaD).toBe(d / 10n);
652
+ expect(res.deltas).toEqual([100_000_000n, 100_000_000n]);
653
+ expect(res.nextTotalLp).toBe(2_200_000_000n);
654
+ });
655
+
656
+ it("rejects a negative offer", () => {
657
+ expect(() =>
658
+ calculatePinnedDeposit([-1n, 1n], [1_000n, 1_000n], 1_000n, UNIT_D),
659
+ ).toThrow("Deposit amounts must be non-negative");
660
+ });
661
+
662
+ it("rejects a pool with no LP supply", () => {
663
+ expect(() =>
664
+ calculatePinnedDeposit([1n, 1n], [1_000n, 1_000n], 0n, UNIT_D),
665
+ ).toThrow("Not enough pool liquidity");
666
+ });
667
+ });
668
+
669
+ describe("V4StableswapPool.pinnedDepositFromAnchor", () => {
670
+ it("fills the other leg in proportion", () => {
671
+ const d = getD(AMP, 1_000_000_000n, 500_000_000n);
672
+ const [a, b] = pinnedDepositFromAnchor(0, 100_000_000n, [1_000_000_000n, 500_000_000n], d);
673
+ expect(a).toBe(100_000_000n);
674
+ expect(b).toBe(50_000_000n);
675
+ });
676
+
677
+ it("returns zeros for a zero anchor", () => {
678
+ expect(
679
+ pinnedDepositFromAnchor(0, 0n, [1_000n, 1_000n], UNIT_D),
680
+ ).toEqual([0n, 0n]);
681
+ });
682
+
683
+ it("rejects an out-of-range anchor", () => {
684
+ expect(() =>
685
+ pinnedDepositFromAnchor(2, 1n, [1_000n, 1_000n], UNIT_D),
686
+ ).toThrow("anchor index is out of range");
687
+ });
688
+ });
@@ -2,4 +2,5 @@ export * as ConcentratedLiquidityPool from "./ConcentratedLiquidityPool.js";
2
2
  export * as ConstantProductPool from "./ConstantProductPool.js";
3
3
  export * as ConstantSumPool from "./ConstantSumPool.js";
4
4
  export * as StableSwapsPool from "./StableSwapsPool.js";
5
+ export * as V4StableswapPool from "./V4StableswapPool.js";
5
6
  export * as SharedPoolMath from "./SharedPoolMath.js";