Skip to content

Commit 7894449

Browse files
authored
Merge branch 'main' into main
2 parents ccc619f + 329e18c commit 7894449

64 files changed

Lines changed: 12909 additions & 136 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/ammCalculator.ts

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
/**
2+
* AMM Calculator — constant-product pool calculations for Stellar liquidity pools.
3+
*
4+
* Provides pure calculation functions for estimating swap output, price impact,
5+
* and proportional pool shares from LiquidityPoolRecord data without additional
6+
* Horizon calls.
7+
*/
8+
9+
import { InsufficientLiquidityError } from "./errors.js";
10+
import type { PoolSwapEstimate, PoolShareResult } from "./types.js";
11+
12+
/**
13+
* Default threshold: input exceeding 30 % of pool reserves triggers
14+
* InsufficientLiquidityError. Can be overridden via the optional `maxRatio`
15+
* parameter on estimateSwapOutput.
16+
*/
17+
const DEFAULT_MAX_INPUT_RATIO = 0.3;
18+
19+
/**
20+
* Estimates the output amount and price impact for a swap against a Stellar
21+
* constant-product (x * y = k) liquidity pool.
22+
*
23+
* @param pool - The liquidity pool record (must include reserves).
24+
* @param inputAmount - The amount of the input asset, in stroops.
25+
* @param inputAsset - The asset being sold into the pool (must match one of the
26+
* pool's reserve assets).
27+
* @param maxRatio - Maximum allowed ratio of input to reserve. Defaults to
28+
* 0.3 (30 %). When exceeded an InsufficientLiquidityError
29+
* is thrown.
30+
* @returns A PoolSwapEstimate with the expected output amount and price impact
31+
* percentage string.
32+
*/
33+
export function estimateSwapOutput(
34+
pool: { reserves: { asset: string; amount: string }[] },
35+
inputAmount: string,
36+
inputAsset: string,
37+
maxRatio: number = DEFAULT_MAX_INPUT_RATIO
38+
): PoolSwapEstimate {
39+
if (pool.reserves.length < 2) {
40+
throw new InsufficientLiquidityError(
41+
"Pool must have at least two reserve assets",
42+
"0",
43+
inputAmount
44+
);
45+
}
46+
47+
// Locate the input reserve and the output reserve.
48+
const inputReserve = pool.reserves.find(
49+
(r) => r.asset === inputAsset
50+
);
51+
const outputReserve = pool.reserves.find(
52+
(r) => r.asset !== inputAsset
53+
);
54+
55+
if (!inputReserve || !outputReserve) {
56+
throw new InsufficientLiquidityError(
57+
`Asset ${inputAsset} not found in pool reserves`,
58+
"0",
59+
inputAmount
60+
);
61+
}
62+
63+
const reserveIn = BigInt(inputReserve.amount);
64+
const reserveOut = BigInt(outputReserve.amount);
65+
const amountIn = BigInt(inputAmount);
66+
67+
if (reserveIn <= 0n || reserveOut <= 0n) {
68+
throw new InsufficientLiquidityError(
69+
"Pool has zero reserves",
70+
"0",
71+
inputAmount
72+
);
73+
}
74+
75+
if (amountIn <= 0n) {
76+
return {
77+
outputAmount: "0",
78+
priceImpactPercent: "0.00",
79+
inputAsset,
80+
outputAsset: outputReserve.asset,
81+
effectivePrice: "0",
82+
spotPrice: computeSpotPrice(reserveIn, reserveOut),
83+
};
84+
}
85+
86+
// Check against max ratio threshold
87+
const ratio = Number(amountIn) / Number(reserveIn);
88+
if (ratio > maxRatio) {
89+
throw new InsufficientLiquidityError(
90+
`Input amount exceeds ${(maxRatio * 100).toFixed(0)}% of pool reserves`,
91+
inputReserve.amount,
92+
inputAmount
93+
);
94+
}
95+
96+
// Constant-product formula: Δy = (y * Δx) / (x + Δx)
97+
// More precisely: outputAmount = reserveOut - (k / (reserveIn + amountIn))
98+
// where k = reserveIn * reserveOut
99+
const k = reserveIn * reserveOut;
100+
const newReserveIn = reserveIn + amountIn;
101+
const newReserveOut = k / newReserveIn;
102+
const outputAmount = reserveOut - newReserveOut;
103+
104+
// Spot price = reserveOut / reserveIn (how many output tokens per input token)
105+
const spotPrice = computeSpotPrice(reserveIn, reserveOut);
106+
107+
// Effective price = outputAmount / inputAmount
108+
const effectivePrice = computeEffectivePrice(outputAmount, amountIn);
109+
110+
// Price impact = (spotPrice - effectivePrice) / spotPrice * 100
111+
const priceImpactPercent = computePriceImpact(spotPrice, effectivePrice);
112+
113+
return {
114+
outputAmount: outputAmount.toString(),
115+
priceImpactPercent,
116+
inputAsset,
117+
outputAsset: outputReserve.asset,
118+
effectivePrice,
119+
spotPrice,
120+
};
121+
}
122+
123+
/**
124+
* Calculates the proportional pool share for a given number of liquidity pool
125+
* shares.
126+
*
127+
* @param pool - The liquidity pool record.
128+
* @param sharesOwned - Number of pool shares owned, in stroops.
129+
* @returns A PoolShareResult with the proportional reserves for both assets.
130+
*/
131+
export function calculatePoolShare(
132+
pool: { reserves: { asset: string; amount: string }[]; totalShares: string },
133+
sharesOwned: string
134+
): PoolShareResult {
135+
if (pool.reserves.length < 2) {
136+
throw new InsufficientLiquidityError(
137+
"Pool must have at least two reserve assets",
138+
"0",
139+
"0"
140+
);
141+
}
142+
143+
const totalShares = BigInt(pool.totalShares);
144+
const owned = BigInt(sharesOwned);
145+
146+
if (totalShares <= 0n) {
147+
throw new InsufficientLiquidityError(
148+
"Pool has zero total shares",
149+
"0",
150+
"0"
151+
);
152+
}
153+
154+
if (owned <= 0n) {
155+
return {
156+
shareOfAssetA: "0",
157+
shareOfAssetB: "0",
158+
assetA: pool.reserves[0].asset,
159+
assetB: pool.reserves[1].asset,
160+
totalShares: totalShares.toString(),
161+
sharesOwned: "0",
162+
ownershipPercent: "0.00",
163+
};
164+
}
165+
166+
const reserveA = BigInt(pool.reserves[0].amount);
167+
const reserveB = BigInt(pool.reserves[1].amount);
168+
169+
// Proportional share: (owned / totalShares) * reserve
170+
const shareOfAssetA = (reserveA * owned) / totalShares;
171+
const shareOfAssetB = (reserveB * owned) / totalShares;
172+
173+
const ownershipPercent = computeOwnershipPercent(owned, totalShares);
174+
175+
return {
176+
shareOfAssetA: shareOfAssetA.toString(),
177+
shareOfAssetB: shareOfAssetB.toString(),
178+
assetA: pool.reserves[0].asset,
179+
assetB: pool.reserves[1].asset,
180+
totalShares: totalShares.toString(),
181+
sharesOwned: owned.toString(),
182+
ownershipPercent,
183+
};
184+
}
185+
186+
// ---------------------------------------------------------------------------
187+
// Internal helpers
188+
// ---------------------------------------------------------------------------
189+
190+
function computeSpotPrice(reserveIn: bigint, reserveOut: bigint): string {
191+
// spotPrice = reserveOut / reserveIn as a decimal string
192+
if (reserveIn === 0n) return "0";
193+
return formatRatio(reserveOut, reserveIn);
194+
}
195+
196+
function computeEffectivePrice(outputAmount: bigint, inputAmount: bigint): string {
197+
if (inputAmount === 0n) return "0";
198+
return formatRatio(outputAmount, inputAmount);
199+
}
200+
201+
function computePriceImpact(spotPrice: string, effectivePrice: string): string {
202+
// Parse the decimal strings safely by treating them as fractional strings.
203+
// Since formatRatio now outputs exact decimal strings, we parse them as
204+
// pairs of (integer, fractional) parts for high precision.
205+
const parseDecimal = (s: string): { int: bigint; frac: bigint; scale: bigint } => {
206+
const dot = s.indexOf(".");
207+
if (dot === -1) return { int: BigInt(s), frac: 0n, scale: 1n };
208+
const intPart = BigInt(s.slice(0, dot));
209+
const fracPartStr = s.slice(dot + 1).padEnd(12, "0");
210+
const scale = 10n ** BigInt(fracPartStr.length);
211+
return { int: intPart, frac: BigInt(fracPartStr), scale };
212+
};
213+
214+
const spot = parseDecimal(spotPrice);
215+
const effective = parseDecimal(effectivePrice);
216+
217+
const spotScaled = spot.int * spot.scale + spot.frac;
218+
const effectiveScaled = effective.int * effective.scale + effective.frac;
219+
220+
if (spotScaled === 0n) return "0.00";
221+
222+
// (spot - effective) / spot * 100 with 4 decimal places of precision
223+
const SCALE = 10000n;
224+
const numerator = (spotScaled - effectiveScaled) * SCALE * 100n;
225+
const denominator = spotScaled;
226+
227+
if (numerator <= 0n) return "0.00";
228+
229+
const result = numerator / denominator;
230+
const intPart = result / SCALE;
231+
const fracPart = result % SCALE;
232+
const fracStr = fracPart.toString().padStart(4, "0").slice(0, 2);
233+
return `${intPart}.${fracStr}`;
234+
}
235+
236+
function computeOwnershipPercent(owned: bigint, total: bigint): string {
237+
if (total === 0n) return "0.00";
238+
// (owned / total) * 100 with 2 decimal places using BigInt
239+
const SCALE = 10000n; // 100 * 100 for 2 decimal places
240+
const scaled = (owned * SCALE * 100n) / total;
241+
const intPart = scaled / SCALE;
242+
const fracPart = scaled % SCALE;
243+
const fracStr = fracPart.toString().padStart(4, "0").slice(0, 2);
244+
return `${intPart}.${fracStr}`;
245+
}
246+
247+
function formatRatio(numerator: bigint, denominator: bigint): string {
248+
if (denominator === 0n) return "0";
249+
// Use BigInt-safe decimal division with up to 12 decimal places.
250+
// Multiply numerator by 10^12 before division, then insert decimal point.
251+
const SCALE = 10n ** 12n;
252+
const scaled = (numerator * SCALE) / denominator;
253+
const intPart = scaled / SCALE;
254+
const fracPart = scaled % SCALE;
255+
const fracStr = fracPart.toString().padStart(12, "0").replace(/0+$/, "");
256+
return fracStr.length > 0 ? `${intPart}.${fracStr}` : intPart.toString();
257+
}

0 commit comments

Comments
 (0)