diff --git a/src/pricing/depth.ts b/src/pricing/depth.ts index f926119c..0f75dd4a 100644 --- a/src/pricing/depth.ts +++ b/src/pricing/depth.ts @@ -6,19 +6,40 @@ export interface DepthLevel { size: number } +export interface ExcludedPool { + poolId: string + reason: string +} + export interface DepthResult { + status: 'ok' + network: string + pairKey: string spotPrice: number executionPrice: number slippagePct: number asks: DepthLevel[] bids: DepthLevel[] - source: 'AMM' | 'SDEX' | 'BOTH' + source: 'AMM' + pool: { + poolId: string + feeBp: number + feeReadAt: string + reservesObservedAt: string + } + excludedPools: ExcludedPool[] } -/** - * Calculates output amount for constant product AMM with fees: - * dy = (y * dx * (1 - fee)) / (x + dx * (1 - fee)) - */ +export interface DepthUnavailable { + status: 'unavailable' + network: string + pairKey: string + reason: string + excludedPools: ExcludedPool[] +} + +export type DepthOutcome = DepthResult | DepthUnavailable + export function calculateAMMOutput(reserveA: number, reserveB: number, amount: number, feeBp: number): number { const fee = 1 - feeBp / 10000 const effectiveInput = amount * fee @@ -60,19 +81,19 @@ export function generateAMMDepthLevels( reserveB: number, feeBp: number, numLevels: number = 10, - stepSize: number = 0.01 + stepSize: number = 0.01, ): { asks: DepthLevel[]; bids: DepthLevel[] } { const asks: DepthLevel[] = [] const bids: DepthLevel[] = [] - // Generate asks (selling asset A for B) — price is B per A + // Asks: selling asset A for B. Price is B per A. for (let i = 1; i <= numLevels; i++) { const amountA = reserveA * stepSize * i const price = calculateAMMPrice(reserveA, reserveB, amountA, feeBp) asks.push({ price, size: amountA }) } - // Generate bids (buying asset A with B) + // Bids: buying asset A with B. Price is B paid per A received. for (let i = 1; i <= numLevels; i++) { const amountB = reserveB * stepSize * i const fee = 1 - feeBp / 10000 @@ -85,65 +106,117 @@ export function generateAMMDepthLevels( return { asks, bids } } -export async function getAMMDepth( - pairKey: string, - amount: number, - network: NetworkName = activeNetwork -): Promise { - // Always network-scoped. `network` defaults to `activeNetwork`, so there is - // no caller that can ask for an unscoped read — which is the point: these - // tables carry rows for every network the process indexes, and an unfiltered - // read mixed testnet reserves into a mainnet depth answer. +interface AmmPoolRow { + poolId: string + reserveA: number + reserveB: number + observedAt: Date +} + +/** Latest reserve snapshot per AMM pool for the pair, on one network only. */ +async function loadLatestAmmPools(network: string, pairKey: string): Promise { const result = await pgPool.query( - `SELECT DISTINCT ON (ps.pool_id) ps.reserve_a, ps.reserve_b, ps.fee_bp - FROM pool_snapshots ps - WHERE ps.network = $2 AND ps.pool_id IN ( + `SELECT DISTINCT ON (ps.pool_id) ps.pool_id, ps.reserve_a, ps.reserve_b, ps.timestamp + FROM pool_snapshots ps + WHERE ps.network = $2 + AND ps.pool_id IN ( SELECT DISTINCT pool_id FROM price_points WHERE pair_key = $1 AND network = $2 AND source = 'AMM' AND pool_id IS NOT NULL ) - ORDER BY ps.pool_id, ps.timestamp DESC - LIMIT 1`, - [pairKey, network] + ORDER BY ps.pool_id, ps.timestamp DESC`, + [pairKey, network], ) - if (!result.rows[0]) return null - - const { reserve_a, reserve_b, fee_bp } = result.rows[0] - const rA = parseFloat(reserve_a) - const rB = parseFloat(reserve_b) - const feeBp = parseInt(fee_bp ?? '30', 10) - - const spotPrice = calculateAMMSpotPrice(rA, rB) - const executionPrice = calculateAMMPrice(rA, rB, amount, feeBp) - const slippagePct = calculateSlippage(spotPrice, executionPrice) - const { asks, bids } = generateAMMDepthLevels(rA, rB, feeBp) - - return { - spotPrice, - executionPrice, - slippagePct, - asks, - bids, - source: 'AMM', - } + return result.rows + .map((r: { pool_id: string; reserve_a: string; reserve_b: string; timestamp: Date }) => ({ + poolId: r.pool_id, + reserveA: parseFloat(r.reserve_a), + reserveB: parseFloat(r.reserve_b), + observedAt: r.timestamp, + })) + .sort((a, b) => (a.poolId < b.poolId ? -1 : a.poolId > b.poolId ? 1 : 0)) } +/** + * Prices an AMM swap using each pool's own fee, read from the venue. + * + * Pools whose fee cannot be read, or whose reserves are empty, are excluded and + * listed with the reason. If no pool survives, the result is "unavailable" with + * no numbers, never zeros. + */ export async function getDepth( + network: string, pairKey: string, amount: number, - network: NetworkName = activeNetwork -): Promise { - const ammDepth = await getAMMDepth(pairKey, amount, network) - - if (ammDepth) { - return ammDepth + feeOpts: PoolFeeOptions, +): Promise { + if (!(amount > 0)) { + throw new RangeError('amount must be greater than 0') + } + + const pools = await loadLatestAmmPools(network, pairKey) + const excludedPools: ExcludedPool[] = [] + + if (pools.length === 0) { + return { + status: 'unavailable', + network, + pairKey, + reason: 'no AMM pool snapshots for this pair on this network', + excludedPools, + } + } + + let chosen: DepthResult | null = null + + for (const pool of pools) { + const feeRes = await getPoolFee(network, pool.poolId, feeOpts) + if (!feeRes.ok) { + excludedPools.push({ poolId: pool.poolId, reason: feeRes.reason }) + continue + } + if (!(pool.reserveA > 0 && pool.reserveB > 0)) { + excludedPools.push({ poolId: pool.poolId, reason: 'pool has no reserves in its latest snapshot' }) + continue + } + if (chosen) continue // keep checking fees so every excluded pool is reported + + const { feeBp, feeReadAt } = feeRes.fee + const spotPrice = calculateAMMSpotPrice(pool.reserveA, pool.reserveB) + const executionPrice = calculateAMMPrice(pool.reserveA, pool.reserveB, amount, feeBp) + const slippagePct = + spotPrice > 0 ? Math.abs(((executionPrice - spotPrice) / spotPrice) * 100) : 0 + const { asks, bids } = generateAMMDepthLevels(pool.reserveA, pool.reserveB, feeBp) + + chosen = { + status: 'ok', + network, + pairKey, + spotPrice, + executionPrice, + slippagePct, + asks, + bids, + source: 'AMM', + pool: { + poolId: pool.poolId, + feeBp, + feeReadAt, + reservesObservedAt: pool.observedAt.toISOString(), + }, + excludedPools, + } + } + + if (chosen) { + chosen.excludedPools = excludedPools + return chosen } return { - spotPrice: 0, - executionPrice: 0, - slippagePct: 0, - asks: [], - bids: [], - source: 'AMM', + status: 'unavailable', + network, + pairKey, + reason: 'every AMM pool for this pair was excluded', + excludedPools, } } diff --git a/src/pricing/pool-fee.ts b/src/pricing/pool-fee.ts new file mode 100644 index 00000000..4cf9c06f --- /dev/null +++ b/src/pricing/pool-fee.ts @@ -0,0 +1,80 @@ +export interface PoolFee { + poolId: string + feeBp: number + /** ISO timestamp of when the venue was read. */ + feeReadAt: string +} + +export type PoolFeeResult = + | { ok: true; fee: PoolFee; cached: boolean } + | { ok: false; poolId: string; reason: string } + +export interface PoolFeeOptions { + /** Horizon base URL for the network being answered, e.g. https://horizon-testnet.stellar.org */ + horizonUrl: string + /** Cache lifetime in milliseconds. Placeholder default; confirm before merge. */ + ttlMs?: number + /** Injectable clock for tests. */ + now?: () => number + /** Injectable fetch for tests. */ + fetchImpl?: typeof fetch +} + +const DEFAULT_TTL_MS = 10 * 60 * 1000 + +interface CacheEntry { + fee: PoolFee + expiresAt: number +} + +// Keyed by network and pool, so testnet and mainnet never share a fee. +const cache = new Map() + +export function clearPoolFeeCache(): void { + cache.clear() +} + +/** + * Reads a pool's fee from the venue (Horizon), with a TTL cache. + * + * Failures are never cached, so a transient outage is retried on the next call. + * A caller that receives { ok: false } must exclude the pool from execution + * estimates and report the reason. It must not fall back to a default fee. + */ +export async function getPoolFee( + network: string, + poolId: string, + opts: PoolFeeOptions, +): Promise { + const now = opts.now ?? Date.now + const ttl = opts.ttlMs ?? DEFAULT_TTL_MS + const key = `${network}:${poolId}` + + const hit = cache.get(key) + if (hit && hit.expiresAt > now()) { + return { ok: true, fee: hit.fee, cached: true } + } + + const fetchImpl = opts.fetchImpl ?? fetch + let body: unknown + try { + const res = await fetchImpl( + `${opts.horizonUrl}/liquidity_pools/${encodeURIComponent(poolId)}`, + ) + if (!res.ok) { + return { ok: false, poolId, reason: `venue responded ${res.status}` } + } + body = await res.json() + } catch (err) { + return { ok: false, poolId, reason: `venue unreachable: ${(err as Error).message}` } + } + + const feeBp = (body as { fee_bp?: unknown } | null)?.fee_bp + if (typeof feeBp !== 'number' || !Number.isInteger(feeBp) || feeBp < 0 || feeBp >= 10000) { + return { ok: false, poolId, reason: 'venue did not return a valid fee_bp' } + } + + const fee: PoolFee = { poolId, feeBp, feeReadAt: new Date(now()).toISOString() } + cache.set(key, { fee, expiresAt: now() + ttl }) + return { ok: true, fee, cached: false } +} diff --git a/tests/depth.test.ts b/tests/depth.test.ts new file mode 100644 index 00000000..b118bee3 --- /dev/null +++ b/tests/depth.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest' + +// ── Mocks ──────────────────────────────────────────────────────────────────── +// Path guesses: confirm these match your repo. + +vi.mock('../src/db', () => ({ pgPool: { query: vi.fn() } })) + +vi.mock('../src/config', () => ({ + activeNetwork: 'testnet', + getNetworkConfig: () => ({ horizon: { url: 'https://horizon.example.test' } }), +})) + +vi.mock('../src/pairs', () => ({ + findPair: (assetA: string, assetB: string) => { + if (assetA === 'XLM' && assetB === 'USDC') { + return { + pairKey: 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM', + assetA: { code: 'XLM' }, + assetB: { code: 'USDC' }, + } + } + return undefined + }, +})) + +// ── Imports (after mocks) ──────────────────────────────────────────────────── + +import { pgPool } from '../src/db' +import { buildApp } from '../src/app' +import { calculateAMMPrice } from '../src/pricing/depth' +import { clearPoolFeeCache } from '../src/pricing/pool-fee' + +const query = pgPool.query as unknown as ReturnType +const ts = new Date('2026-10-17T10:00:00Z') + +/** Venue stub: fee per pool id, or 'error' to simulate an unreadable fee. */ +function venue(fees: Record) { + return async (url: string | URL | Request) => { + const poolId = String(url).split('/').pop()! + const fee = fees[poolId] + if (fee === undefined || fee === 'error') { + return new Response('not found', { status: 404 }) + } + return new Response(JSON.stringify({ id: poolId, fee_bp: fee }), { status: 200 }) + } +} + +function poolRows(...ids: string[]) { + return { rows: ids.map((id) => ({ pool_id: id, reserve_a: '1000', reserve_b: '120', timestamp: ts })) } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +let app: Awaited> + +beforeEach(async () => { + clearPoolFeeCache() + query.mockReset() + app = await buildApp() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +afterAll(async () => { + await app?.close() +}) + +describe('GET /price/:assetA/:assetB/depth', () => { + it('200: prices with the venue fee and reports the pool that was used', async () => { + query.mockResolvedValue(poolRows('p1')) + vi.stubGlobal('fetch', venue({ p1: 50 })) + + const res = await app.inject({ method: 'GET', url: '/price/XLM/USDC/depth?amount=10' }) + const body = res.json() + + expect(res.statusCode).toBe(200) + expect(body.status).toBe('ok') + expect(body.network).toBe('testnet') + expect(body.assetA).toBe('XLM') + expect(body.assetB).toBe('USDC') + expect(body.pool.poolId).toBe('p1') + expect(body.pool.feeBp).toBe(50) + expect(body.executionPrice).toBeCloseTo(calculateAMMPrice(1000, 120, 10, 50), 12) + expect(body.excludedPools).toEqual([]) + }) + + it('200: skips a pool with an unreadable fee and lists it as excluded', async () => { + query.mockResolvedValue(poolRows('p1', 'p2')) + vi.stubGlobal('fetch', venue({ p1: 'error', p2: 30 })) + + const res = await app.inject({ method: 'GET', url: '/price/XLM/USDC/depth?amount=10' }) + const body = res.json() + + expect(res.statusCode).toBe(200) + expect(body.pool.poolId).toBe('p2') + expect(body.excludedPools).toHaveLength(1) + expect(body.excludedPools[0].poolId).toBe('p1') + expect(body.excludedPools[0].reason).toMatch(/venue responded 404/) + }) + + it('503: returns unavailable with no numbers when every pool is excluded', async () => { + query.mockResolvedValue(poolRows('p1')) + vi.stubGlobal('fetch', venue({ p1: 'error' })) + + const res = await app.inject({ method: 'GET', url: '/price/XLM/USDC/depth?amount=10' }) + const body = res.json() + + expect(res.statusCode).toBe(503) + expect(body.status).toBe('unavailable') + expect(body.reason).toBe('every AMM pool for this pair was excluded') + expect(body).not.toHaveProperty('spotPrice') + expect(body).not.toHaveProperty('executionPrice') + expect(body.excludedPools).toHaveLength(1) + }) + + it('503: returns unavailable when the pair has no AMM pool snapshots on this network', async () => { + query.mockResolvedValue({ rows: [] }) + vi.stubGlobal('fetch', venue({})) + + const res = await app.inject({ method: 'GET', url: '/price/XLM/USDC/depth?amount=10' }) + + expect(res.statusCode).toBe(503) + expect(res.json().status).toBe('unavailable') + expect(res.json().excludedPools).toEqual([]) + }) + + it('404: returns not found for a pair that is not watched', async () => { + const res = await app.inject({ method: 'GET', url: '/price/ABC/XYZ/depth?amount=10' }) + + expect(res.statusCode).toBe(404) + expect(res.json().error).toMatch(/not watched/) + expect(query).not.toHaveBeenCalled() + }) + + it.each([ + ['zero', '0'], + ['negative', '-5'], + ['non-numeric', 'abc'], + ['trailing junk', '1000abc'], + ['empty', ''], + ])('400: rejects an amount that is %s', async (_label, amount) => { + const res = await app.inject({ method: 'GET', url: `/price/XLM/USDC/depth?amount=${amount}` }) + + expect(res.statusCode).toBe(400) + expect(res.json().error).toBe('amount must be a positive number') + expect(query).not.toHaveBeenCalled() + }) + + it('defaults to the active network when none is given', async () => { + query.mockResolvedValue({ rows: [] }) + vi.stubGlobal('fetch', venue({})) + + await app.inject({ method: 'GET', url: '/price/XLM/USDC/depth?amount=10' }) + + const params = query.mock.calls[0][1] as unknown[] + expect(params).toContain('testnet') + }) +}) diff --git a/tests/pool-fee.test.ts b/tests/pool-fee.test.ts new file mode 100644 index 00000000..0bc7c66e --- /dev/null +++ b/tests/pool-fee.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { getPoolFee, clearPoolFeeCache } from '../src/pricing/pool-fee' + +const HORIZON = 'https://horizon.example.test' + +function fakeFetch(body: unknown, status = 200) { + const calls: string[] = [] + const fn = (async (url: string) => { + calls.push(url) + return new Response(JSON.stringify(body), { status }) + }) as unknown as typeof fetch + return { fn, calls } +} + +describe('getPoolFee', () => { + beforeEach(() => clearPoolFeeCache()) + + it('reads fee_bp from the venue and caches it', async () => { + let t = 0 + const { fn, calls } = fakeFetch({ id: 'p1', fee_bp: 50 }) + const opts = { horizonUrl: HORIZON, now: () => t, fetchImpl: fn } + + const first = await getPoolFee('testnet', 'p1', opts) + const second = await getPoolFee('testnet', 'p1', opts) + + expect(first.ok && first.fee.feeBp).toBe(50) + expect(first.ok && first.cached).toBe(false) + expect(second.ok && second.cached).toBe(true) + expect(calls).toHaveLength(1) + }) + + it('refetches after the TTL expires', async () => { + let t = 0 + const { fn, calls } = fakeFetch({ id: 'p1', fee_bp: 30 }) + const opts = { horizonUrl: HORIZON, ttlMs: 1000, now: () => t, fetchImpl: fn } + + await getPoolFee('testnet', 'p1', opts) + t = 1001 + await getPoolFee('testnet', 'p1', opts) + + expect(calls).toHaveLength(2) + }) + + it('does not cache a failed read, so the next call retries', async () => { + const bad = fakeFetch({ id: 'p1' }) // no fee_bp + const good = fakeFetch({ id: 'p1', fee_bp: 30 }) + const base = { horizonUrl: HORIZON, now: () => 0 } + + const first = await getPoolFee('testnet', 'p1', { ...base, fetchImpl: bad.fn }) + expect(first.ok).toBe(false) + + const second = await getPoolFee('testnet', 'p1', { ...base, fetchImpl: good.fn }) + expect(second.ok && second.fee.feeBp).toBe(30) + }) + + it('rejects a missing, non-integer or out-of-range fee', async () => { + const base = { horizonUrl: HORIZON, now: () => 0 } + for (const body of [{}, { fee_bp: 30.5 }, { fee_bp: -1 }, { fee_bp: 10000 }, { fee_bp: '30' }]) { + clearPoolFeeCache() + const r = await getPoolFee('testnet', 'p1', { ...base, fetchImpl: fakeFetch(body).fn }) + expect(r.ok, JSON.stringify(body)).toBe(false) + } + }) + + it('reports venue HTTP errors as a reason, not a throw', async () => { + const r = await getPoolFee('testnet', 'p1', { + horizonUrl: HORIZON, + now: () => 0, + fetchImpl: fakeFetch({}, 404).fn, + }) + expect(r).toEqual({ ok: false, poolId: 'p1', reason: 'venue responded 404' }) + }) + + it('keeps networks apart in the cache', async () => { + const testnet = fakeFetch({ fee_bp: 30 }) + const mainnet = fakeFetch({ fee_bp: 50 }) + const base = { horizonUrl: HORIZON, now: () => 0 } + + await getPoolFee('testnet', 'p1', { ...base, fetchImpl: testnet.fn }) + const m = await getPoolFee('mainnet', 'p1', { ...base, fetchImpl: mainnet.fn }) + + expect(m.ok && m.fee.feeBp).toBe(50) + expect(mainnet.calls).toHaveLength(1) + }) +})