diff --git a/src/liquidityPool.ts b/src/liquidityPool.ts new file mode 100644 index 0000000..c4ea64c --- /dev/null +++ b/src/liquidityPool.ts @@ -0,0 +1,230 @@ +/** + * Liquidity Pool Integration for StellarSplit SDK. + * + * Provides utilities to query Stellar AMM liquidity pool state, + * calculate swap estimates, and manage pool participation. + */ + +export interface LiquidityPoolAsset { + code: string; + issuer?: string; + amount: bigint; +} + +export interface LiquidityPool { + id: string; + fee: number; // basis points, e.g. 30 = 0.3% + assetA: LiquidityPoolAsset; + assetB: LiquidityPoolAsset; + totalShares: bigint; + lastUpdatedLedger: number; +} + +export interface SwapEstimate { + poolId: string; + inputAsset: string; + outputAsset: string; + inputAmount: bigint; + outputAmount: bigint; + priceImpactBps: number; + fee: bigint; +} + +export interface PoolDepositResult { + poolId: string; + sharesIssued: bigint; + depositedA: bigint; + depositedB: bigint; +} + +export interface PoolWithdrawResult { + poolId: string; + sharesRedeemed: bigint; + receivedA: bigint; + receivedB: bigint; +} + +export interface LiquidityPoolClientOptions { + /** Minimum liquidity (in stroops) required to consider a pool valid */ + minLiquidityThreshold?: bigint; + /** Maximum price impact in basis points before the client warns */ + maxPriceImpactBps?: number; +} + +/** Constant-product AMM invariant: k = reserveA * reserveB */ +function invariant(reserveA: bigint, reserveB: bigint): bigint { + return reserveA * reserveB; +} + +/** + * Calculate the output amount for a constant-product AMM swap. + * Formula: outputAmount = (reserveOut * inputAmount * (10000 - feeBps)) / + * (reserveIn * 10000 + inputAmount * (10000 - feeBps)) + */ +export function calculateSwapOutput( + inputAmount: bigint, + reserveIn: bigint, + reserveOut: bigint, + feeBps: number +): bigint { + if (inputAmount <= 0n) throw new RangeError('inputAmount must be positive'); + if (reserveIn <= 0n || reserveOut <= 0n) throw new RangeError('reserves must be positive'); + if (feeBps < 0 || feeBps >= 10000) throw new RangeError('feeBps must be in [0, 10000)'); + + const feeMultiplier = BigInt(10000 - feeBps); + const numerator = reserveOut * inputAmount * feeMultiplier; + const denominator = reserveIn * 10000n + inputAmount * feeMultiplier; + return numerator / denominator; +} + +/** + * Calculate the price impact of a swap as basis points. + * Price impact = ((spotPrice - executionPrice) / spotPrice) * 10000 + */ +export function calculatePriceImpactBps( + inputAmount: bigint, + outputAmount: bigint, + reserveIn: bigint, + reserveOut: bigint +): number { + if (inputAmount <= 0n || outputAmount <= 0n) return 0; + // Spot price: reserveOut / reserveIn (in units of outputAsset per inputAsset) + // Execution price: outputAmount / inputAmount + // Impact = (spotPrice - executionPrice) / spotPrice + const spotNumerator = reserveOut * inputAmount * 10000n; + const spotDenominator = reserveIn * outputAmount; + if (spotDenominator === 0n) return 0; + const impactBps = spotNumerator / spotDenominator; + const result = Number(impactBps) - 10000; + return Math.max(0, result); +} + +/** + * LiquidityPoolClient manages interactions with Stellar AMM liquidity pools. + */ +export class LiquidityPoolClient { + private readonly pools = new Map(); + private readonly options: Required; + + constructor(options: LiquidityPoolClientOptions = {}) { + this.options = { + minLiquidityThreshold: options.minLiquidityThreshold ?? 1_000_000n, + maxPriceImpactBps: options.maxPriceImpactBps ?? 200, + }; + } + + /** + * Register a pool with the client (e.g. populated from Horizon). + */ + registerPool(pool: LiquidityPool): void { + this.pools.set(pool.id, pool); + } + + /** + * Retrieve a registered pool by ID. + */ + getPool(poolId: string): LiquidityPool | undefined { + return this.pools.get(poolId); + } + + /** + * List all registered pool IDs. + */ + listPoolIds(): string[] { + return Array.from(this.pools.keys()); + } + + /** + * Estimate the output of swapping inputAmount of assetA for assetB in the given pool. + * Throws if pool not found or liquidity is below threshold. + */ + estimateSwap(poolId: string, inputAsset: 'A' | 'B', inputAmount: bigint): SwapEstimate { + const pool = this.pools.get(poolId); + if (!pool) throw new Error(`Pool ${poolId} not found`); + + const [reserveIn, reserveOut, inCode, outCode] = + inputAsset === 'A' + ? [pool.assetA.amount, pool.assetB.amount, pool.assetA.code, pool.assetB.code] + : [pool.assetB.amount, pool.assetA.amount, pool.assetB.code, pool.assetA.code]; + + const totalLiquidity = pool.assetA.amount + pool.assetB.amount; + if (totalLiquidity < this.options.minLiquidityThreshold) { + throw new Error(`Pool ${poolId} liquidity ${totalLiquidity} below threshold ${this.options.minLiquidityThreshold}`); + } + + const outputAmount = calculateSwapOutput(inputAmount, reserveIn, reserveOut, pool.fee); + const priceImpactBps = calculatePriceImpactBps(inputAmount, outputAmount, reserveIn, reserveOut); + const feeAmount = (inputAmount * BigInt(pool.fee)) / 10000n; + + if (priceImpactBps > this.options.maxPriceImpactBps) { + throw new Error( + `Price impact ${priceImpactBps} bps exceeds maximum allowed ${this.options.maxPriceImpactBps} bps for pool ${poolId}` + ); + } + + return { + poolId, + inputAsset: inCode, + outputAsset: outCode, + inputAmount, + outputAmount, + priceImpactBps, + fee: feeAmount, + }; + } + + /** + * Simulate a deposit into the pool, returning the shares issued. + * Amounts are deposited proportional to pool reserves. + */ + simulateDeposit( + poolId: string, + amountA: bigint, + amountB: bigint + ): PoolDepositResult { + const pool = this.pools.get(poolId); + if (!pool) throw new Error(`Pool ${poolId} not found`); + if (amountA <= 0n || amountB <= 0n) throw new RangeError('deposit amounts must be positive'); + + let sharesIssued: bigint; + if (pool.totalShares === 0n) { + // Initial deposit: shares = sqrt(amountA * amountB) + sharesIssued = bigintSqrt(amountA * amountB); + } else { + // Pro-rata shares: min of both sides + const sharesA = (amountA * pool.totalShares) / pool.assetA.amount; + const sharesB = (amountB * pool.totalShares) / pool.assetB.amount; + sharesIssued = sharesA < sharesB ? sharesA : sharesB; + } + + return { poolId, sharesIssued, depositedA: amountA, depositedB: amountB }; + } + + /** + * Simulate a withdrawal, returning the amounts received. + */ + simulateWithdraw(poolId: string, shares: bigint): PoolWithdrawResult { + const pool = this.pools.get(poolId); + if (!pool) throw new Error(`Pool ${poolId} not found`); + if (shares <= 0n) throw new RangeError('shares must be positive'); + if (shares > pool.totalShares) throw new RangeError('shares exceed total supply'); + + const receivedA = (shares * pool.assetA.amount) / pool.totalShares; + const receivedB = (shares * pool.assetB.amount) / pool.totalShares; + + return { poolId, sharesRedeemed: shares, receivedA, receivedB }; + } +} + +/** Integer square root for bigint */ +function bigintSqrt(n: bigint): bigint { + if (n < 0n) throw new RangeError('sqrt of negative'); + if (n === 0n) return 0n; + let x = n; + let y = (x + 1n) / 2n; + while (y < x) { + x = y; + y = (x + n / x) / 2n; + } + return x; +} diff --git a/test/liquidityPool.test.ts b/test/liquidityPool.test.ts new file mode 100644 index 0000000..592b621 --- /dev/null +++ b/test/liquidityPool.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import { + LiquidityPoolClient, + calculateSwapOutput, + calculatePriceImpactBps, + type LiquidityPool, +} from '../src/liquidityPool'; + +const makePool = (overrides: Partial = {}): LiquidityPool => ({ + id: 'pool-abc', + fee: 30, // 0.3% + assetA: { code: 'XLM', amount: 1_000_000_000n }, + assetB: { code: 'USDC', amount: 500_000_000n }, + totalShares: 700_000_000n, + lastUpdatedLedger: 12345, + ...overrides, +}); + +describe('calculateSwapOutput', () => { + it('returns positive output for valid inputs', () => { + const out = calculateSwapOutput(1_000_000n, 1_000_000_000n, 500_000_000n, 30); + expect(out).toBeGreaterThan(0n); + }); + + it('throws for zero input amount', () => { + expect(() => calculateSwapOutput(0n, 1_000n, 1_000n, 30)).toThrow(); + }); + + it('throws for zero reserves', () => { + expect(() => calculateSwapOutput(100n, 0n, 1_000n, 30)).toThrow(); + }); + + it('throws for invalid fee bps', () => { + expect(() => calculateSwapOutput(100n, 1_000n, 1_000n, 10000)).toThrow(); + }); + + it('higher fee results in less output', () => { + const low = calculateSwapOutput(1_000_000n, 1_000_000_000n, 500_000_000n, 10); + const high = calculateSwapOutput(1_000_000n, 1_000_000_000n, 500_000_000n, 100); + expect(low).toBeGreaterThan(high); + }); +}); + +describe('calculatePriceImpactBps', () => { + it('returns 0 for zero amounts', () => { + expect(calculatePriceImpactBps(0n, 0n, 1000n, 1000n)).toBe(0); + }); + + it('large trade has higher impact than small trade', () => { + const small = calculatePriceImpactBps(1_000n, 100_000n, 1_000_000n, 1_000_000n); + const large = calculatePriceImpactBps(500_000n, 100_000n, 1_000_000n, 1_000_000n); + expect(large).toBeGreaterThanOrEqual(small); + }); +}); + +describe('LiquidityPoolClient', () => { + it('registers and retrieves a pool', () => { + const client = new LiquidityPoolClient(); + const pool = makePool(); + client.registerPool(pool); + expect(client.getPool('pool-abc')).toEqual(pool); + }); + + it('listPoolIds returns registered pool IDs', () => { + const client = new LiquidityPoolClient(); + client.registerPool(makePool({ id: 'pool-1' })); + client.registerPool(makePool({ id: 'pool-2' })); + expect(client.listPoolIds()).toContain('pool-1'); + expect(client.listPoolIds()).toContain('pool-2'); + }); + + it('estimateSwap returns valid estimate', () => { + const client = new LiquidityPoolClient(); + client.registerPool(makePool()); + const est = client.estimateSwap('pool-abc', 'A', 1_000_000n); + expect(est.outputAmount).toBeGreaterThan(0n); + expect(est.fee).toBeGreaterThan(0n); + expect(est.inputAsset).toBe('XLM'); + expect(est.outputAsset).toBe('USDC'); + }); + + it('estimateSwap throws for unknown pool', () => { + const client = new LiquidityPoolClient(); + expect(() => client.estimateSwap('no-pool', 'A', 100n)).toThrow('not found'); + }); + + it('estimateSwap throws for high price impact', () => { + const client = new LiquidityPoolClient({ maxPriceImpactBps: 1 }); + client.registerPool(makePool()); + // Very large trade will trigger price impact + expect(() => client.estimateSwap('pool-abc', 'A', 900_000_000n)).toThrow(); + }); + + it('estimateSwap throws when pool below liquidity threshold', () => { + const client = new LiquidityPoolClient({ minLiquidityThreshold: 10_000_000_000n }); + client.registerPool(makePool()); + expect(() => client.estimateSwap('pool-abc', 'A', 1000n)).toThrow('liquidity'); + }); + + it('simulateDeposit returns positive shares for initial deposit', () => { + const client = new LiquidityPoolClient(); + client.registerPool(makePool({ totalShares: 0n })); + const result = client.simulateDeposit('pool-abc', 1_000_000n, 500_000n); + expect(result.sharesIssued).toBeGreaterThan(0n); + }); + + it('simulateDeposit returns pro-rata shares for existing pool', () => { + const client = new LiquidityPoolClient(); + client.registerPool(makePool()); + const result = client.simulateDeposit('pool-abc', 100_000_000n, 50_000_000n); + expect(result.sharesIssued).toBeGreaterThan(0n); + }); + + it('simulateWithdraw returns correct amounts', () => { + const client = new LiquidityPoolClient(); + client.registerPool(makePool()); + const result = client.simulateWithdraw('pool-abc', 350_000_000n); // 50% of shares + expect(result.receivedA).toBe(500_000_000n); + expect(result.receivedB).toBe(250_000_000n); + }); + + it('simulateWithdraw throws for excessive shares', () => { + const client = new LiquidityPoolClient(); + client.registerPool(makePool()); + expect(() => client.simulateWithdraw('pool-abc', 9_999_999_999n)).toThrow('exceed'); + }); +});