Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 230 additions & 0 deletions src/liquidityPool.ts
Original file line number Diff line number Diff line change
@@ -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<string, LiquidityPool>();
private readonly options: Required<LiquidityPoolClientOptions>;

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;
}
127 changes: 127 additions & 0 deletions test/liquidityPool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import {
LiquidityPoolClient,
calculateSwapOutput,
calculatePriceImpactBps,
type LiquidityPool,
} from '../src/liquidityPool';

const makePool = (overrides: Partial<LiquidityPool> = {}): 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');
});
});