@sundaeswap/math 0.4.0 → 0.5.1

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.
@@ -6,13 +6,15 @@ import {
6
6
  calculateDepositN,
7
7
  calculateLiquidity,
8
8
  calculatePinnedDeposit,
9
+ calculateZap,
9
10
  getSwapInput,
10
11
  getSwapOutput,
11
12
  pinnedDepositFromAnchor,
12
13
  } from "../ConstantSumPool.js";
13
14
 
14
15
  const tokenA: IAssetAmountMetadata = {
15
- assetId: "09169bb6f5ff5b246d65d65935b2222cc53b5e677d7ed22771878972.744f4b454e41",
16
+ assetId:
17
+ "09169bb6f5ff5b246d65d65935b2222cc53b5e677d7ed22771878972.744f4b454e41",
16
18
  decimals: 0,
17
19
  };
18
20
 
@@ -103,9 +105,13 @@ describe("ConstantSumPool.getSwapOutput", () => {
103
105
 
104
106
  describe("ConstantSumPool.getSwapInput", () => {
105
107
  it("throws on non-positive output, reserves, or prices", () => {
106
- expect(() => getSwapInput(tokenA, 0n, 10n, 10n, 1n, 1n, threePct)).toThrow();
108
+ expect(() =>
109
+ getSwapInput(tokenA, 0n, 10n, 10n, 1n, 1n, threePct),
110
+ ).toThrow();
107
111
  expect(() => getSwapInput(tokenA, 1n, 0n, 10n, 1n, 1n, threePct)).toThrow();
108
- expect(() => getSwapInput(tokenA, 1n, 10n, 10n, 1n, 0n, threePct)).toThrow();
112
+ expect(() =>
113
+ getSwapInput(tokenA, 1n, 10n, 10n, 1n, 0n, threePct),
114
+ ).toThrow();
109
115
  });
110
116
 
111
117
  it("throws when the output exceeds the output reserve, but allows draining it", () => {
@@ -272,9 +278,7 @@ describe("ConstantSumPool.calculateLiquidity", () => {
272
278
  // Deltas never exceed the offers, and match the validator's ceil pin.
273
279
  result.deltas.forEach((delta, i) => {
274
280
  expect(delta).toBeLessThanOrEqual(offered[i]);
275
- expect(delta).toEqual(
276
- (reserves[i] * result.targetDeltaV + vB - 1n) / vB,
277
- );
281
+ expect(delta).toEqual((reserves[i] * result.targetDeltaV + vB - 1n) / vB);
278
282
  });
279
283
  expect(result.generatedLp).toEqual(
280
284
  (totalLp * (vB + result.targetDeltaV)) / vB - totalLp,
@@ -320,3 +324,160 @@ describe("ConstantSumPool.calculateLiquidity", () => {
320
324
  ).toThrow();
321
325
  });
322
326
  });
327
+
328
+ describe("ConstantSumPool.calculateZap", () => {
329
+ // ADA/MINT-shaped pool at prices [2, 5]: 1M A + 400k B = 4M value, balanced.
330
+ const prices = [2n, 5n];
331
+ const reserves = [1_000_000n, 400_000n];
332
+ const totalLp = 4_000_000n;
333
+ const fee = new Fraction(30n, 10_000n);
334
+
335
+ /** The scooper's fill for an arbitrary dx — reference for the optimality scan. */
336
+ const fillAt = (
337
+ dx: bigint,
338
+ offered: bigint[],
339
+ res: bigint[],
340
+ px: bigint[],
341
+ lp: bigint,
342
+ f: Fraction,
343
+ ): bigint => {
344
+ const [x, y] = [0, 1];
345
+ const inputValue = dx * px[x];
346
+ const feeValue = (inputValue * f.numerator) / f.denominator;
347
+ const dy = (inputValue - feeValue) / px[y];
348
+ const basket = [offered[x] - dx, offered[y] + dy];
349
+ const next = [res[x] + dx, res[y] - dy];
350
+ if (basket.some((a) => a <= 0n) || next.some((r) => r <= 0n)) return 0n;
351
+ try {
352
+ return calculatePinnedDeposit(basket, next, px, lp).generatedLp;
353
+ } catch {
354
+ return 0n;
355
+ }
356
+ };
357
+
358
+ it("rejects anything but two assets, bad prices or reserves, and empty or negative offers", () => {
359
+ expect(() =>
360
+ calculateZap([1n, 1n, 1n], [1n, 1n, 1n], [1n, 1n, 1n], 1n, fee),
361
+ ).toThrow("exactly two assets");
362
+ expect(() =>
363
+ calculateZap([1n, 0n], reserves, [0n, 5n], totalLp, fee),
364
+ ).toThrow("Prices");
365
+ expect(() =>
366
+ calculateZap([1n, 0n], [0n, 1n], prices, totalLp, fee),
367
+ ).toThrow("Reserves");
368
+ expect(() =>
369
+ calculateZap([0n, 0n], reserves, prices, totalLp, fee),
370
+ ).toThrow("at least one positive");
371
+ expect(() =>
372
+ calculateZap([-1n, 1n], reserves, prices, totalLp, fee),
373
+ ).toThrow();
374
+ expect(() => calculateZap([1n, 1n], reserves, prices, 0n, fee)).toThrow(
375
+ "liquidity",
376
+ );
377
+ expect(() =>
378
+ calculateZap([1n, 1n], reserves, prices, totalLp, Fraction.ONE),
379
+ ).toThrow("fee");
380
+ });
381
+
382
+ it("is a plain pinned deposit when the basket already matches the reserves", () => {
383
+ const offered = [100_000n, 40_000n];
384
+ const zap = calculateZap(offered, reserves, prices, totalLp, fee);
385
+ const pinned = calculatePinnedDeposit(offered, reserves, prices, totalLp);
386
+
387
+ expect(zap.swapIndex).toBeUndefined();
388
+ expect(zap.swapInput).toBe(0n);
389
+ expect(zap.swapOutput).toBe(0n);
390
+ expect(zap.nextReserves).toEqual(reserves);
391
+ expect(zap.depositBasket).toEqual(offered);
392
+ expect(zap.generatedLp).toBe(pinned.generatedLp);
393
+ expect(zap.change).toEqual([0n, 0n]);
394
+ });
395
+
396
+ it("single-sided A: swaps the closed-form dx, deposits the rest, leaves dust change", () => {
397
+ const zap = calculateZap([100_000n, 0n], reserves, prices, totalLp, fee);
398
+
399
+ // dx = (a_X·r_Y)·feeDen·p_Y / ((feeDen−feeNum)·p_X·(r_X+a_X) + feeDen·p_Y·r_Y)
400
+ // = 2e15 / 41_934_000_000 = 47_693
401
+ expect(zap.swapIndex).toBe(0);
402
+ expect(zap.swapInput).toBe(47_693n);
403
+ // getSwapOutput semantics: floor((95_386 − floor(95_386·0.003)) / 5)
404
+ expect(zap.swapOutput).toBe(19_020n);
405
+ expect(zap.nextReserves).toEqual([1_047_693n, 380_980n]);
406
+ expect(zap.depositBasket).toEqual([52_307n, 19_020n]);
407
+
408
+ // The pin is the already-tested function applied to that basket.
409
+ const pinned = calculatePinnedDeposit(
410
+ zap.depositBasket,
411
+ zap.nextReserves,
412
+ prices,
413
+ totalLp,
414
+ );
415
+ expect(zap.deltas).toEqual(pinned.deltas);
416
+ expect(zap.generatedLp).toBe(pinned.generatedLp);
417
+ expect(zap.change).toEqual([
418
+ zap.depositBasket[0] - pinned.deltas[0],
419
+ zap.depositBasket[1] - pinned.deltas[1],
420
+ ]);
421
+ // Rounding leaves at most a unit or two behind.
422
+ expect(zap.change[0] + zap.change[1]).toBeLessThanOrEqual(2n);
423
+
424
+ // A 200k-value basket into a 4M pool with 4M LP is 200k LP before fees;
425
+ // half of it swaps at 0.3%, so the loss is ~0.15%.
426
+ expect(zap.generatedLp).toBeLessThan(200_000n);
427
+ expect(zap.generatedLp).toBeGreaterThan(199_400n);
428
+ });
429
+
430
+ it("swap leg agrees with getSwapOutput for the same dx", () => {
431
+ const zap = calculateZap([100_000n, 0n], reserves, prices, totalLp, fee);
432
+ const swap = getSwapOutput(
433
+ tokenA,
434
+ zap.swapInput,
435
+ reserves[0],
436
+ reserves[1],
437
+ prices[0],
438
+ prices[1],
439
+ fee,
440
+ );
441
+ expect(zap.swapOutput).toBe(swap.output);
442
+ expect(zap.nextReserves).toEqual([
443
+ swap.nextInputReserve,
444
+ swap.nextOutputReserve,
445
+ ]);
446
+ });
447
+
448
+ it("single-sided B swaps in the other direction", () => {
449
+ const zap = calculateZap([0n, 40_000n], reserves, prices, totalLp, fee);
450
+ expect(zap.swapIndex).toBe(1);
451
+ expect(zap.swapInput).toBeGreaterThan(0n);
452
+ expect(zap.depositBasket[0]).toBeGreaterThan(0n);
453
+ expect(zap.depositBasket[1]).toBeLessThan(40_000n);
454
+ expect(zap.nextReserves[0]).toBeLessThan(reserves[0]);
455
+ expect(zap.nextReserves[1]).toBeGreaterThan(reserves[1]);
456
+ });
457
+
458
+ it("closed-form dx maximizes minted LP over every integer dx", () => {
459
+ // Small enough to scan exhaustively; skewed prices and reserves so the
460
+ // ratio work is non-trivial.
461
+ const px = [3n, 7n];
462
+ const res = [5_000n, 2_000n];
463
+ const lp = 29_000n;
464
+ const f = new Fraction(25n, 10_000n);
465
+ const offered = [1_200n, 50n];
466
+
467
+ const zap = calculateZap(offered, res, px, lp, f);
468
+ expect(zap.swapIndex).toBe(0);
469
+
470
+ let best = 0n;
471
+ for (let dx = 0n; dx <= offered[0]; dx++) {
472
+ const got = fillAt(dx, offered, res, px, lp, f);
473
+ if (got > best) best = got;
474
+ }
475
+ expect(zap.generatedLp).toBe(best);
476
+ });
477
+
478
+ it("throws when the offer is too small to survive the swap", () => {
479
+ expect(() =>
480
+ calculateZap([1n, 0n], reserves, prices, totalLp, fee),
481
+ ).toThrow("too small");
482
+ });
483
+ });
@@ -103,6 +103,59 @@ describe("getPrice()", () => {
103
103
  Math.abs(normalCppPrice.toNumber() - 1.0),
104
104
  );
105
105
  });
106
+
107
+ // |dx/dy| of the on-chain invariant 4A(x + y) + D = 4AD + D^3/(4xy), as an
108
+ // exact rational on the scaled reserves. Independent of getPrice's integer
109
+ // arithmetic, so it pins both the amplification term and the orientation.
110
+ const exactSpot = (aReserve: bigint, bReserve: bigint, laf: bigint) => {
111
+ const d = getSumInvariant(laf, aReserve, bReserve);
112
+ const x = aReserve * RESERVE_PRECISION;
113
+ const y = bReserve * RESERVE_PRECISION;
114
+ const dCubed = d * d * d;
115
+ return new Fraction(
116
+ x * (16n * laf * x * y * y + dCubed),
117
+ y * (16n * laf * x * x * y + dCubed),
118
+ );
119
+ };
120
+ const relativeError = (actual: Fraction, expected: Fraction) =>
121
+ Math.abs(actual.toNumber() / expected.toNumber() - 1);
122
+
123
+ const offParPools: [bigint, bigint, bigint][] = [
124
+ [250275094796n, 250515678047n, 500n],
125
+ [1_000_000_000n, 1_100_000_000n, 100n],
126
+ [2_000_000_000n, 1_000_000_000n, 50n],
127
+ [5_000_000_000n, 1_000_000_000n, 10n],
128
+ [1_010_000_000n, 990_000_250n, 200n],
129
+ ];
130
+
131
+ test.each(offParPools)(
132
+ "matches the derivative of the on-chain invariant (a=%s, b=%s, laf=%s)",
133
+ (aReserve, bReserve, laf) => {
134
+ const price = getPrice(aReserve, bReserve, laf);
135
+ expect(
136
+ relativeError(price, exactSpot(aReserve, bReserve, laf)),
137
+ ).toBeLessThan(1e-9);
138
+ },
139
+ );
140
+
141
+ test.each(offParPools)(
142
+ "agrees with a central finite difference of getNewY (a=%s, b=%s, laf=%s)",
143
+ (aReserve, bReserve, laf) => {
144
+ const dA = 1000n;
145
+ const d = getSumInvariant(laf, aReserve, bReserve);
146
+ const lowY = getNewY(aReserve + dA, laf, d);
147
+ const highY = getNewY(aReserve - dA, laf, d);
148
+ const slope = new Fraction(dA * 2n * RESERVE_PRECISION, highY - lowY);
149
+ expect(
150
+ relativeError(getPrice(aReserve, bReserve, laf), slope),
151
+ ).toBeLessThan(1e-9);
152
+ },
153
+ );
154
+
155
+ test("issue #172: preview pool 488fe776 at laf 500", () => {
156
+ const price = getPrice(250275094796n, 250515678047n, 500n);
157
+ expect(price.toNumber()).toBeCloseTo(0.999999040146, 11);
158
+ });
106
159
  });
107
160
 
108
161
  describe("getSwapOutput(usdc,)", () => {
@@ -199,7 +252,7 @@ describe("getSwapOutput(usdc,)", () => {
199
252
  out: 0n,
200
253
  outReserve: [3n, 2n],
201
254
  lpFee: 0n,
202
- impact: new Fraction(0n, 0n),
255
+ impact: new Fraction(0n, 1n),
203
256
  },
204
257
  {
205
258
  id: 2,
@@ -209,7 +262,7 @@ describe("getSwapOutput(usdc,)", () => {
209
262
  out: 1n,
210
263
  outReserve: [3n, 2n],
211
264
  lpFee: 0n,
212
- impact: new Fraction(249n, 1000000n),
265
+ impact: new Fraction(31n, 250000n),
213
266
  },
214
267
  {
215
268
  id: 3,
@@ -219,7 +272,7 @@ describe("getSwapOutput(usdc,)", () => {
219
272
  out: 2n,
220
273
  outReserve: [102n, 1n],
221
274
  lpFee: 0n,
222
- impact: new Fraction(245001n, 250000n),
275
+ impact: new Fraction(490001n, 500000n),
223
276
  },
224
277
  {
225
278
  id: 4,
@@ -229,7 +282,7 @@ describe("getSwapOutput(usdc,)", () => {
229
282
  out: 99n,
230
283
  outReserve: [102n, 1n],
231
284
  lpFee: 0n,
232
- impact: new Fraction(244453n, 1000000n),
285
+ impact: new Fraction(14333n, 100000n),
233
286
  },
234
287
  {
235
288
  id: 5,
@@ -260,7 +313,7 @@ describe("getSwapOutput(usdc,)", () => {
260
313
  out: 0n,
261
314
  outReserve: [3n, 2n],
262
315
  lpFee: 0n,
263
- impact: new Fraction(0n, 0n),
316
+ impact: new Fraction(0n, 1n),
264
317
  },
265
318
  {
266
319
  id: 8,
@@ -270,7 +323,7 @@ describe("getSwapOutput(usdc,)", () => {
270
323
  out: 0n,
271
324
  outReserve: [3n, 3n],
272
325
  lpFee: 1n,
273
- impact: new Fraction(249n, 1000000n),
326
+ impact: new Fraction(31n, 250000n),
274
327
  },
275
328
  {
276
329
  id: 9,
@@ -280,7 +333,7 @@ describe("getSwapOutput(usdc,)", () => {
280
333
  out: 0n,
281
334
  outReserve: [3n, 6n],
282
335
  lpFee: 1n,
283
- impact: new Fraction(499n, 1000000n),
336
+ impact: new Fraction(249n, 1000000n),
284
337
  },
285
338
  {
286
339
  id: 10,
@@ -290,7 +343,7 @@ describe("getSwapOutput(usdc,)", () => {
290
343
  out: 1n,
291
344
  outReserve: [102n, 2n],
292
345
  lpFee: 1n,
293
- impact: new Fraction(495001n, 500000n),
346
+ impact: new Fraction(990001n, 1000000n),
294
347
  },
295
348
  {
296
349
  id: 11,
@@ -300,7 +353,7 @@ describe("getSwapOutput(usdc,)", () => {
300
353
  out: 196n,
301
354
  outReserve: [202n, 3n],
302
355
  lpFee: 1n,
303
- impact: new Fraction(559n, 1000n),
356
+ impact: new Fraction(393153n, 1000000n),
304
357
  },
305
358
  {
306
359
  id: 12,
@@ -332,7 +385,7 @@ describe("getSwapOutput(usdc,)", () => {
332
385
  out: 2139306697n,
333
386
  outReserve: [7044962984n, 670265463643n],
334
387
  lpFee: 21829661n,
335
- impact: new Fraction(380983n, 1000000n),
388
+ impact: new Fraction(101761n, 1000000n),
336
389
  },
337
390
  {
338
391
  id: 15,
@@ -342,7 +395,7 @@ describe("getSwapOutput(usdc,)", () => {
342
395
  out: 1024031n,
343
396
  outReserve: [3696261028076n, 77871392800159n],
344
397
  lpFee: 3091n,
345
- impact: new Fraction(34239n, 1000000n),
398
+ impact: new Fraction(6001n, 1000000n),
346
399
  },
347
400
  ] as {
348
401
  input: bigint;
@@ -466,31 +519,31 @@ describe("getSwapInput", () => {
466
519
  input: 1n,
467
520
  reserves: [1n, 2n],
468
521
  fee: zeroPct,
469
- impact: new Fraction(0n, 2000n),
522
+ impact: new Fraction(0n, 4000n),
470
523
  },
471
524
  {
472
525
  input: 100n,
473
526
  reserves: [1n, 2n],
474
527
  fee: zeroPct,
475
- impact: new Fraction(0n, 2000n),
528
+ impact: new Fraction(0n, 4000n),
476
529
  },
477
530
  {
478
531
  input: 100n,
479
532
  reserves: [1n, 100n],
480
533
  fee: zeroPct,
481
- impact: new Fraction(245025n, 445500n),
534
+ impact: new Fraction(245025n, 643500n),
482
535
  },
483
536
  {
484
537
  input: 100n,
485
538
  reserves: [100n, 100n],
486
539
  fee: zeroPct,
487
- impact: new Fraction(400400n, 20020000n),
540
+ impact: new Fraction(800400n, 40020000n),
488
541
  },
489
542
  {
490
543
  input: 50n,
491
544
  reserves: [100n, 100n],
492
545
  fee: zeroPct,
493
- impact: new Fraction(200200n, 10010000n),
546
+ impact: new Fraction(400200n, 20010000n),
494
547
  },
495
548
 
496
549
  // Simple boundary cases
@@ -498,25 +551,25 @@ describe("getSwapInput", () => {
498
551
  input: 10n,
499
552
  reserves: [10n, 30n],
500
553
  fee: threePct,
501
- impact: new Fraction(20315n, 200450n),
554
+ impact: new Fraction(40315n, 400450n),
502
555
  },
503
556
  {
504
557
  input: 100n,
505
558
  reserves: [1n, 100n],
506
559
  fee: threePct,
507
- impact: new Fraction(251100n, 445500n),
560
+ impact: new Fraction(257100n, 643500n),
508
561
  },
509
562
  {
510
563
  input: 100n,
511
564
  reserves: [100n, 100n],
512
565
  fee: threePct,
513
- impact: new Fraction(1001000n, 20020000n),
566
+ impact: new Fraction(2001000n, 40020000n),
514
567
  },
515
568
  {
516
569
  input: 50n,
517
570
  reserves: [100n, 100n],
518
571
  fee: threePct,
519
- impact: new Fraction(600600n, 10010000n),
572
+ impact: new Fraction(1200600n, 20010000n),
520
573
  },
521
574
 
522
575
  // // Real world examples
@@ -524,19 +577,19 @@ describe("getSwapInput", () => {
524
577
  input: 5n,
525
578
  reserves: [84159832107n, 123172010729n],
526
579
  fee: fivePct,
527
- impact: new Fraction(168806906319235n, 842673001141955n),
580
+ impact: new Fraction(337126570533235n, 1684271322211955n),
528
581
  },
529
582
  {
530
583
  input: 100n,
531
584
  reserves: [84159832107n, 123172010729n],
532
585
  fee: fivePct,
533
- impact: new Fraction(849140270799500n, 16853460022839100n),
586
+ impact: new Fraction(1690738591869500n, 33685426444239100n),
534
587
  },
535
588
  {
536
589
  input: 1291591603n,
537
590
  reserves: [5753371381n, 672426600000n],
538
591
  fee: onePct,
539
- impact: new Fraction(15118057654414078594677n, 40350603201831427592080n),
592
+ impact: new Fraction(5112429850089508046677n, 55212615531111655078080n),
540
593
  },
541
594
  ] as {
542
595
  input: bigint;