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
89 changes: 89 additions & 0 deletions apps/backend/src/lib/stellar/validate-asset-pairs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,92 @@ describe('validateCustomizationConfig — asset pair integration', () => {
expect(result.errors.some(e => e.code === 'ASSET_INVALID_ISSUER')).toBe(true);
});
});

// ── checkBridgeLiquidity tests (#793) ─────────────────────────────────────────

import { describe as describe793, it as it793, expect as expect793, vi as vi793, beforeEach as beforeEach793 } from 'vitest';
import {
checkBridgeLiquidity,
clearLiquidityCache,
MIN_LIQUIDITY_USD,
LIQUIDITY_CACHE_TTL_MS,
} from '@/lib/stellar/validate-asset-pairs';
import type { HorizonOrderBookResponse } from '@/lib/stellar/validate-asset-pairs';

const ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';
const pairXlmUsdc: AssetPair = {
base: { type: 'native', code: 'XLM', issuer: '' },
counter: { type: 'credit_alphanum4', code: 'USDC', issuer: ISSUER },
};

function deepBook(): HorizonOrderBookResponse {
// Each side: 10 levels × 200 amount × price ~1 → depth ≈ 2000 > MIN
return {
bids: Array(10).fill({ price: '1.0000', amount: '200.0000' }),
asks: Array(10).fill({ price: '1.0100', amount: '200.0000' }),
};
}

function shallowBook(): HorizonOrderBookResponse {
// depth = 1 × 0.5 = 0.5 → way below MIN_LIQUIDITY_USD
return {
bids: [{ price: '1.0000', amount: '0.5000' }],
asks: [{ price: '1.0100', amount: '0.5000' }],
};
}

describe793('checkBridgeLiquidity (#793)', () => {
beforeEach793(() => clearLiquidityCache());

it793('returns liquidityWarning:false when depth exceeds minimum', async () => {
const fetch = vi793.fn().mockResolvedValue(deepBook());
const result = await checkBridgeLiquidity(pairXlmUsdc, fetch);

expect793(result.liquidityWarning).toBe(false);
expect793(result.valid).toBe(true);
});

it793('returns liquidityWarning:true with depth when below minimum', async () => {
const fetch = vi793.fn().mockResolvedValue(shallowBook());
const result = await checkBridgeLiquidity(pairXlmUsdc, fetch);

expect793(result.valid).toBe(true);
expect793(result.liquidityWarning).toBe(true);
if (result.liquidityWarning) {
expect793(result.depth).toBeLessThan(MIN_LIQUIDITY_USD);
}
});

it793('caches results for 5 minutes — second call does not re-fetch', async () => {
const fetch = vi793.fn().mockResolvedValue(deepBook());

await checkBridgeLiquidity(pairXlmUsdc, fetch);
await checkBridgeLiquidity(pairXlmUsdc, fetch);

expect793(fetch).toHaveBeenCalledOnce();
});

it793('re-fetches after cache expires', async () => {
vi793.useFakeTimers();
const fetch = vi793.fn().mockResolvedValue(deepBook());

await checkBridgeLiquidity(pairXlmUsdc, fetch);
vi793.advanceTimersByTime(LIQUIDITY_CACHE_TTL_MS + 1);
await checkBridgeLiquidity(pairXlmUsdc, fetch);

expect793(fetch).toHaveBeenCalledTimes(2);
vi793.useRealTimers();
});

it793('uses minimum of bid and ask depth for the warning threshold', async () => {
// Bids deep, asks shallow
const asymmetric: HorizonOrderBookResponse = {
bids: Array(20).fill({ price: '1.0000', amount: '500.0' }),
asks: [{ price: '1.01', amount: '1.0' }], // depth ≈ 1.01 < MIN
};
const fetch = vi793.fn().mockResolvedValue(asymmetric);
const result = await checkBridgeLiquidity(pairXlmUsdc, fetch);

expect793(result.liquidityWarning).toBe(true);
});
});
86 changes: 86 additions & 0 deletions apps/backend/src/lib/stellar/validate-asset-pairs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,89 @@ export function validateAssetPairs(pairs: unknown): ValidationError[] {

return errors;
}

// ── Bridge Liquidity Check (#793) ─────────────────────────────────────────────

/** Minimum USD-equivalent depth required on each side of the order book. */
export const MIN_LIQUIDITY_USD = 1_000;

/** Cache TTL: 5 minutes in milliseconds. */
export const LIQUIDITY_CACHE_TTL_MS = 5 * 60 * 1_000;

export type LiquidityCheckResult =
| { valid: true; liquidityWarning: false }
| { valid: true; liquidityWarning: true; depth: number };

interface CacheEntry {
result: LiquidityCheckResult;
storedAt: number;
}

const liquidityCache = new Map<string, CacheEntry>();

/** Flush all cached liquidity results (for testing). */
export function clearLiquidityCache(): void {
liquidityCache.clear();
}

function liquidityCacheKey(pair: AssetPair): string {
return `${assetKey(pair.base)}|${assetKey(pair.counter)}`;
}

/**
* Horizon order book response subset used for depth calculation.
* Mirrors the shape returned by `GET /order_book?selling=…&buying=…`.
*/
export interface HorizonOrderBookResponse {
bids: Array<{ price: string; amount: string }>;
asks: Array<{ price: string; amount: string }>;
}

export type OrderBookFetchFn = (pair: AssetPair) => Promise<HorizonOrderBookResponse>;

/**
* Sum the USD-equivalent volume on one side of the order book.
* Each level contributes `price × amount`.
*/
function sumSideDepth(levels: Array<{ price: string; amount: string }>): number {
return levels.reduce((sum, lvl) => {
const p = parseFloat(lvl.price);
const a = parseFloat(lvl.amount);
return sum + (isFinite(p) && isFinite(a) ? p * a : 0);
}, 0);
}

/**
* Check whether sufficient bridge liquidity exists for the given asset pair
* on the Stellar DEX.
*
* Results are cached for 5 minutes per pair. Pass `fetchOrderBook` to
* inject a custom fetcher (required for testing; defaults to Horizon fetch).
*
* @param pair - Asset pair to check
* @param fetchOrderBook - Async function that returns a Horizon order book
* @returns `LiquidityCheckResult` indicating whether liquidity is sufficient
*/
export async function checkBridgeLiquidity(
pair: AssetPair,
fetchOrderBook: OrderBookFetchFn,
): Promise<LiquidityCheckResult> {
const cacheKey = liquidityCacheKey(pair);
const cached = liquidityCache.get(cacheKey);
if (cached && Date.now() - cached.storedAt < LIQUIDITY_CACHE_TTL_MS) {
return cached.result;
}

const book = await fetchOrderBook(pair);
const bidDepth = sumSideDepth(book.bids);
const askDepth = sumSideDepth(book.asks);
const minDepth = Math.min(bidDepth, askDepth);

const result: LiquidityCheckResult =
minDepth >= MIN_LIQUIDITY_USD
? { valid: true, liquidityWarning: false }
: { valid: true, liquidityWarning: true, depth: minDepth };

liquidityCache.set(cacheKey, { result, storedAt: Date.now() });
return result;
}
180 changes: 180 additions & 0 deletions packages/stellar/src/dex-price-feed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Tests for DEX price feed VWAP calculation and outlier detection (#791)
*/
import { describe, it, expect, vi } from 'vitest';
import {
detectOutliers,
computeEnrichedDexPrice,
subscribeLedgerPriceFeed,
} from './dex-price-feed';
import type { OrderBookSnapshot, OrderBookLevel, LedgerEventEmitter, OrderBookFetcher } from './dex-price-feed';

// ── Helpers ──────────────────────────────────────────────────────────────────

function level(price: string, amount = '100'): OrderBookLevel {
const [n, d] = price.split('.').length > 1
? [parseFloat(price) * 10000000, 10000000]
: [parseInt(price), 1];
return { price, amount, price_r: { n, d } };
}

function book(bids: OrderBookLevel[], asks: OrderBookLevel[]): OrderBookSnapshot {
return { bids, asks };
}

// ── detectOutliers ────────────────────────────────────────────────────────────

describe('detectOutliers', () => {
it('returns empty array for fewer than 2 levels', () => {
expect(detectOutliers([])).toEqual([]);
expect(detectOutliers([level('1.0')])).toEqual([]);
});

it('returns empty array when all prices are equal', () => {
const levels = [level('1.0'), level('1.0'), level('1.0')];
expect(detectOutliers(levels)).toEqual([]);
});

it('flags a price more than 3 standard deviations from the mean', () => {
// 20-price cluster of 1.0 with one extreme outlier at 10.0
const levels = Array<OrderBookLevel>(20).fill(level('1.0')).concat([level('10.0')]);
const outliers = detectOutliers(levels);
expect(outliers).toContain(10.0);
});

it('does not flag prices within 3 standard deviations', () => {
const levels = [level('1.0'), level('1.1'), level('0.9'), level('1.05'), level('0.95')];
expect(detectOutliers(levels)).toEqual([]);
});
});

// ── computeEnrichedDexPrice ───────────────────────────────────────────────────

describe('computeEnrichedDexPrice', () => {
it('includes both raw DexPriceResult fields and analysis', () => {
const snapshot = book(
[level('1.0', '200'), level('0.9', '100')],
[level('1.1', '150'), level('1.2', '50')],
);
const result = computeEnrichedDexPrice(snapshot);

expect(result.bestBid).toBe(1.0);
expect(result.bestAsk).toBe(1.1);
expect(result.bidAnalysis).toBeDefined();
expect(result.askAnalysis).toBeDefined();
});

it('computes VWAP on bid side', () => {
// bids: 200 @ 1.0, 100 @ 0.9 => VWAP = (200*1.0 + 100*0.9) / 300 = 0.967
const snapshot = book(
[level('1.0', '200'), level('0.9', '100')],
[],
);
const result = computeEnrichedDexPrice(snapshot);
const expectedVwap = (200 * 1.0 + 100 * 0.9) / 300;
expect(result.bidAnalysis.vwap).toBeCloseTo(expectedVwap, 6);
});

it('computes VWAP on ask side', () => {
const snapshot = book([], [level('1.1', '100'), level('1.2', '400')]);
const result = computeEnrichedDexPrice(snapshot);
const expectedVwap = (100 * 1.1 + 400 * 1.2) / 500;
expect(result.askAnalysis.vwap).toBeCloseTo(expectedVwap, 6);
});

it('sets hasOutlier true when outlier detected', () => {
// 20 prices at 1.0 + one extreme outlier at 10.0 (>3σ from mean)
const bids = Array<OrderBookLevel>(20).fill(level('1.0')).concat([level('10.0')]);
const snapshot = book(bids, []);
const result = computeEnrichedDexPrice(snapshot);
expect(result.bidAnalysis.hasOutlier).toBe(true);
expect(result.bidAnalysis.outliers).toContain(10.0);
});

it('sets hasOutlier false when no outlier', () => {
const snapshot = book(
[level('1.0'), level('1.05'), level('0.95')],
[level('1.1'), level('1.15'), level('1.05')],
);
const result = computeEnrichedDexPrice(snapshot);
expect(result.bidAnalysis.hasOutlier).toBe(false);
expect(result.askAnalysis.hasOutlier).toBe(false);
});

it('handles empty order book', () => {
const result = computeEnrichedDexPrice(book([], []));
expect(result.empty).toBe(true);
expect(result.bidAnalysis.vwap).toBeUndefined();
expect(result.askAnalysis.vwap).toBeUndefined();
});
});

// ── subscribeLedgerPriceFeed ──────────────────────────────────────────────────

describe('subscribeLedgerPriceFeed', () => {
function makeEmitter() {
const handlers = new Set<(l: { sequence: number }) => void>();
const emitter: LedgerEventEmitter = {
on: (_event, handler) => { handlers.add(handler as (l: { sequence: number }) => void); },
off: (_event, handler) => { handlers.delete(handler as (l: { sequence: number }) => void); },
};
const emit = (seq: number) => handlers.forEach(h => h({ sequence: seq }));
return { emitter, emit, handlers };
}

it('calls onUpdate with enriched price after each ledger close', async () => {
const { emitter, emit } = makeEmitter();
const snapshot: OrderBookSnapshot = book([level('1.0', '100')], [level('1.1', '100')]);
const fetcher: OrderBookFetcher = { fetch: vi.fn().mockResolvedValue(snapshot) };
const onUpdate = vi.fn();

subscribeLedgerPriceFeed(emitter, fetcher, onUpdate);
emit(1000);
await new Promise(r => setTimeout(r, 10));

expect(fetcher.fetch).toHaveBeenCalledOnce();
expect(onUpdate).toHaveBeenCalledOnce();
expect(onUpdate.mock.calls[0][0]).toHaveProperty('bidAnalysis');
});

it('returns an unsubscribe function that stops updates', async () => {
const { emitter, emit } = makeEmitter();
const snapshot: OrderBookSnapshot = book([], []);
const fetcher: OrderBookFetcher = { fetch: vi.fn().mockResolvedValue(snapshot) };
const onUpdate = vi.fn();

const unsubscribe = subscribeLedgerPriceFeed(emitter, fetcher, onUpdate);
unsubscribe();
emit(1001);
await new Promise(r => setTimeout(r, 10));

expect(onUpdate).not.toHaveBeenCalled();
});

it('survives a fetch error without crashing', async () => {
const { emitter, emit } = makeEmitter();
const fetcher: OrderBookFetcher = { fetch: vi.fn().mockRejectedValue(new Error('network')) };
const onUpdate = vi.fn();

subscribeLedgerPriceFeed(emitter, fetcher, onUpdate);
emit(1002);
await new Promise(r => setTimeout(r, 10));

expect(onUpdate).not.toHaveBeenCalled();
});

it('triggers on every ledger close', async () => {
const { emitter, emit } = makeEmitter();
const snapshot: OrderBookSnapshot = book([], []);
const fetcher: OrderBookFetcher = { fetch: vi.fn().mockResolvedValue(snapshot) };
const onUpdate = vi.fn();

subscribeLedgerPriceFeed(emitter, fetcher, onUpdate);
emit(1000);
emit(1001);
emit(1002);
await new Promise(r => setTimeout(r, 20));

expect(onUpdate).toHaveBeenCalledTimes(3);
});
});
Loading
Loading