diff --git a/src/__tests__/schemaValidation.test.ts b/src/__tests__/schemaValidation.test.ts index 55f96239..52e508f9 100644 --- a/src/__tests__/schemaValidation.test.ts +++ b/src/__tests__/schemaValidation.test.ts @@ -24,6 +24,10 @@ vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: mockGetBestRoute, })) +vi.mock('../aggregator/splitRoute', () => ({ + getSplitRecommendation: mockGetSplitRecommendation, +})) + vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: mockGetAggregatedPrice, })) @@ -55,7 +59,7 @@ vi.mock('../config', () => ({ })) import { registerRESTRoutes } from '../api/rest' -import { priceResponseSchema } from '../api/schemas' +import { priceResponseSchema, splitResponseSchema } from '../api/schemas' async function buildApp() { const app = Fastify({ logger: false }) diff --git a/src/__tests__/splitRoute.test.ts b/src/__tests__/splitRoute.test.ts new file mode 100644 index 00000000..12e4fa7d --- /dev/null +++ b/src/__tests__/splitRoute.test.ts @@ -0,0 +1,210 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { getSplitRecommendation, fetchSDEXPrice, fetchAMMData } from '../aggregator/splitRoute' +import { _resetHorizonServers } from '../aggregator/bestRoute' +import { pgPool } from '../db' +import * as StellarSdk from '@stellar/stellar-sdk' + +// Mock database +vi.mock('../db', () => ({ + pgPool: { + query: vi.fn(), + }, +})) + +let currentStrictSendAmount: number = 1000 +const mockCallFn = vi.fn() + +vi.mock('@stellar/stellar-sdk', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Horizon: { + Server: vi.fn(function () { + return { + strictSendPaths: vi.fn(function (_from, amountStr) { + currentStrictSendAmount = parseFloat(amountStr) + return { + call: () => mockCallFn(currentStrictSendAmount), + } + }), + } + }), + }, + Asset: Object.assign( + vi.fn(function (code, issuer) { + return { code, issuer } + }), + { native: vi.fn(() => 'native') } + ), + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, + __mockCall: mockCallFn, + } +}) + +describe('getSplitRecommendation', () => { + const assetA = { code: 'USDC', issuer: 'GA2C5RFPE6GCKIG3EQNCIMHO7OA7Q6M2XQ2UBNR5NCTU24VTY4A2J7B2' } + const assetB = { code: 'XLM', issuer: null } + const pairKey = 'USDC:GA2C5RFPE6GCKIG3EQNCIMHO7OA7Q6M2XQ2UBNR5NCTU24VTY4A2J7B2/XLM' + + const mockQuery = vi.mocked(pgPool.query) + + beforeEach(() => { + vi.clearAllMocks() + _resetHorizonServers() + }) + + // Fixed pool: 10,000 A / 10,000 B, 30 bp fee, spot = 1.0 + const standardPool = { + rows: [ + { + reserve_a: '10000', + reserve_b: '10000', + fee_bp: '30', + timestamp: new Date('2026-10-16T12:00:00Z'), + pool_id: 'pool-usdc-xlm-1', + }, + ], + } as any + + it('Case 1: returns split recommendation and proportions when splitting beats single-venue route', async () => { + mockQuery.mockResolvedValue(standardPool) + + // Realistic order book: deep top-of-book at 0.98 for first 2000 units, then drops to 0.75 + mockCallFn.mockImplementation((amount: number) => { + let dest: number + if (amount <= 2000) { + dest = amount * 0.98 + } else { + dest = 2000 * 0.98 + (amount - 2000) * 0.75 + } + return Promise.resolve({ + records: [{ destination_amount: dest.toString() }], + }) + }) + + const amount = 6000 + const result = await getSplitRecommendation(assetA, assetB, pairKey, amount, 'testnet') + + // Acceptance criterion 1 & 2: Proportions per venue returned when splitting beats single venue + expect(result.splitRecommended).toBe(true) + expect(result.proportions).toBeDefined() + expect(result.proportions?.SDEX).toBeGreaterThan(0) + expect(result.proportions?.AMM).toBeGreaterThan(0) + expect(result.proportions!.SDEX + result.proportions!.AMM).toBeCloseTo(1.0, 3) + + // Acceptance criterion 3: Comparison is in the response + expect(result.comparison).toBeDefined() + expect(result.comparison?.singleVenueRoute).toBe('SDEX') + expect(result.comparison!.singleVenueOutput).toBeCloseTo(4960, 1) + expect(result.comparison!.outputImprovement).toBeGreaterThan(0) + expect(result.comparison!.outputImprovementPct).toBeGreaterThan(0) + expect(result.estimatedOutput).toBeGreaterThan(result.comparison!.singleVenueOutput) + + // Allocations detail + expect(result.allocations).toHaveLength(2) + const sdexAlloc = result.allocations?.find((a) => a.venue === 'SDEX') + const ammAlloc = result.allocations?.find((a) => a.venue === 'AMM') + expect(sdexAlloc).toBeDefined() + expect(ammAlloc).toBeDefined() + expect(sdexAlloc!.amount + ammAlloc!.amount).toBeCloseTo(amount, 2) + + // Provenance requirement: numbers carry provenance + expect(result.provenance.venues).toEqual(['SDEX', 'AMM']) + expect(result.provenance.samples).toBeGreaterThan(1) + expect(result.provenance.asOf).toBeDefined() + + // Network requirement: echoed in response + expect(result.network).toBe('testnet') + }) + + it('Case 2: does NOT return split proportions when single-venue route beats splitting', async () => { + mockQuery.mockResolvedValue(standardPool) + + // For a small order of 1000 units, SDEX fills entirely at top-of-book (0.98), beating AMM + mockCallFn.mockImplementation((amount: number) => { + let dest: number + if (amount <= 2000) { + dest = amount * 0.98 + } else { + dest = 2000 * 0.98 + (amount - 2000) * 0.75 + } + return Promise.resolve({ + records: [{ destination_amount: dest.toString() }], + }) + }) + + const amount = 1000 + const result = await getSplitRecommendation(assetA, assetB, pairKey, amount, 'testnet') + + // Acceptance criterion 2: Only returned when it beats the single-venue route + expect(result.splitRecommended).toBe(false) + expect(result.proportions).toBeUndefined() + expect(result.allocations).toBeUndefined() + expect(result.reason).toBe('single_venue_better') + + // Acceptance criterion 3: Comparison in the response showing single-venue winner + expect(result.comparison).toBeDefined() + expect(result.comparison?.singleVenueRoute).toBe('SDEX') + expect(result.comparison?.outputImprovement).toBe(0) + expect(result.singleVenue?.route).toBe('SDEX') + expect(result.singleVenue?.output).toBeCloseTo(980, 1) + }) + + it('Case 3: honest answer when pair only has AMM liquidity', async () => { + mockQuery.mockResolvedValue(standardPool) + mockCallFn.mockResolvedValue({ records: [] }) // SDEX has no paths + + const result = await getSplitRecommendation(assetA, assetB, pairKey, 1000, 'testnet') + + expect(result.splitRecommended).toBe(false) + expect(result.proportions).toBeUndefined() + expect(result.reason).toBe('single_venue_only') + expect(result.message).toContain('Only AMM liquidity available') + expect(result.singleVenue?.route).toBe('AMM') + expect(result.provenance.venues).toEqual(['AMM']) + }) + + it('Case 4: honest answer when pair only has SDEX liquidity', async () => { + mockQuery.mockResolvedValue({ rows: [] } as any) // No pool snapshot + mockCallFn.mockResolvedValue({ + records: [{ destination_amount: '500' }], + }) + + const result = await getSplitRecommendation(assetA, assetB, pairKey, 1000, 'testnet') + + expect(result.splitRecommended).toBe(false) + expect(result.proportions).toBeUndefined() + expect(result.reason).toBe('single_venue_only') + expect(result.message).toContain('Only SDEX liquidity available') + expect(result.singleVenue?.route).toBe('SDEX') + expect(result.provenance.venues).toEqual(['SDEX']) + }) + + it('Case 5: honest answer when neither venue has liquidity (not 0, not 500)', async () => { + mockQuery.mockResolvedValue({ rows: [] } as any) + mockCallFn.mockResolvedValue({ records: [] }) + + const result = await getSplitRecommendation(assetA, assetB, pairKey, 1000, 'testnet') + + expect(result.splitRecommended).toBe(false) + expect(result.proportions).toBeUndefined() + expect(result.reason).toBe('no_liquidity') + expect(result.error).toContain('No liquidity available') + expect(result.provenance.venues).toEqual([]) + }) + + it('Case 6: scopes database query and echo to requested network', async () => { + mockQuery.mockResolvedValue(standardPool) + mockCallFn.mockResolvedValue({ records: [{ destination_amount: '500' }] }) + + const result = await getSplitRecommendation(assetA, assetB, pairKey, 1000, 'mainnet') + + expect(result.network).toBe('mainnet') + const [sql, params] = mockQuery.mock.calls[0] + expect(sql).toContain('ps.network = $2') + expect(params).toEqual([pairKey, 'mainnet']) + }) +}) diff --git a/src/aggregator/bestRoute.ts b/src/aggregator/bestRoute.ts index fe2cb44a..b4b3ff05 100644 --- a/src/aggregator/bestRoute.ts +++ b/src/aggregator/bestRoute.ts @@ -4,6 +4,7 @@ import { getHorizonServer, resetNetworkClients } from '../network/clients' import type { AssetId, RouteInfo, RouteHop, RouteOption, RouteProvenance } from '../types' import { pgPool } from '../db' import { calculateAMMSpotPrice } from '../pricing/depth' +export { getSplitRecommendation } from './splitRoute' function assetIdToStellar(asset: AssetId) { if (!asset.issuer) return Asset.native() diff --git a/src/aggregator/splitRoute.ts b/src/aggregator/splitRoute.ts new file mode 100644 index 00000000..5f0c72d0 --- /dev/null +++ b/src/aggregator/splitRoute.ts @@ -0,0 +1,351 @@ +import { Asset } from '@stellar/stellar-sdk' +import { activeNetwork, type NetworkName } from '../config' +import { getHorizonServer } from '../network/clients' +import type { + AssetId, + SplitRecommendation, + SplitVenueAllocation, + SplitComparison, + SplitProvenance, +} from '../types' +import { pgPool } from '../db' +import { calculateAMMSpotPrice, calculateAMMOutput } from '../pricing/depth' + +function assetIdToStellar(asset: AssetId) { + if (!asset.issuer) return Asset.native() + return new Asset(asset.code, asset.issuer) +} + +export async function fetchSDEXPrice( + assetA: AssetId, + assetB: AssetId, + amount: number, + network: NetworkName +): Promise { + if (amount <= 0) return 0 + try { + const stellarAssetA = assetIdToStellar(assetA) + const stellarAssetB = assetIdToStellar(assetB) + const paths = await getHorizonServer(network) + .strictSendPaths(stellarAssetA, amount.toString(), [stellarAssetB]) + .call() + if (paths.records.length === 0) return 0 + const best = paths.records[0] + return parseFloat(best.destination_amount) / amount + } catch { + return 0 + } +} + +interface AMMData { + rA: number + rB: number + feeBp: number + spotPrice: number + timestamp: Date | null +} + +export async function fetchAMMData( + pairKey: string, + network: NetworkName +): Promise { + const result = await pgPool.query( + `SELECT DISTINCT ON (ps.pool_id) ps.reserve_a, ps.reserve_b, ps.fee_bp, 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] + ) + + if (!result.rows[0]) return null + + const { reserve_a, reserve_b, fee_bp, timestamp } = result.rows[0] + const rA = parseFloat(reserve_a) + const rB = parseFloat(reserve_b) + const feeBp = parseInt(fee_bp, 10) + const spotPrice = calculateAMMSpotPrice(rA, rB) + + return { + rA, + rB, + feeBp, + spotPrice, + timestamp: timestamp ? new Date(timestamp) : null, + } +} + +/** + * Computes optimal split proportions across SDEX and AMM venues for a given swap size. + * + * Returned with split proportions only when splitting actually yields higher output + * (lower total slippage) than the single-venue alternative. + */ +export async function getSplitRecommendation( + assetA: AssetId, + assetB: AssetId, + pairKey: string, + amount: number = 1000, + network: NetworkName = activeNetwork +): Promise { + const now = new Date() + + // 1. Fetch available venue liquidity in parallel + const [sdexFullPrice, amm] = await Promise.all([ + fetchSDEXPrice(assetA, assetB, amount, network), + fetchAMMData(pairKey, network), + ]) + + const hasAmm = amm !== null && amm.rA > 0 && amm.rB > 0 + const hasSdex = sdexFullPrice > 0 + + const poolFreshnessSeconds = amm?.timestamp + ? Math.max(0, Math.round((now.getTime() - amm.timestamp.getTime()) / 1000)) + : null + + // Case A: Neither venue has liquidity — honest answer, not 0 and not 500 + if (!hasAmm && !hasSdex) { + return { + splitRecommended: false, + assetA: assetA.code, + assetB: assetB.code, + pairKey, + network, + amount, + reason: 'no_liquidity', + message: `No liquidity available for pair ${assetA.code}/${assetB.code} on ${network}`, + error: `No liquidity available for pair ${assetA.code}/${assetB.code} on ${network}`, + provenance: { + venues: [], + samples: 0, + asOf: now.toISOString(), + freshnessSeconds: null, + }, + } + } + + // Case B: Only SDEX available + if (!hasAmm && hasSdex) { + const singleVenueOutput = amount * sdexFullPrice + return { + splitRecommended: false, + assetA: assetA.code, + assetB: assetB.code, + pairKey, + network, + amount, + reason: 'single_venue_only', + message: 'Only SDEX liquidity available — splitting across venues is not possible', + singleVenue: { + route: 'SDEX', + output: singleVenueOutput, + price: sdexFullPrice, + slippagePct: 0, + }, + provenance: { + venues: ['SDEX'], + samples: 1, + asOf: now.toISOString(), + freshnessSeconds: 0, + }, + } + } + + // Case C: Only AMM available + if (hasAmm && !hasSdex) { + const ammFullOutput = calculateAMMOutput(amm.rA, amm.rB, amount, amm.feeBp) + const ammFullPrice = ammFullOutput / amount + const ammFullSlippage = + amm.spotPrice > 0 + ? Math.max(0, ((amm.spotPrice - ammFullPrice) / amm.spotPrice) * 100) + : 0 + + return { + splitRecommended: false, + assetA: assetA.code, + assetB: assetB.code, + pairKey, + network, + amount, + reason: 'single_venue_only', + message: 'Only AMM liquidity available — splitting across venues is not possible', + singleVenue: { + route: 'AMM', + output: ammFullOutput, + price: ammFullPrice, + slippagePct: ammFullSlippage, + }, + provenance: { + venues: ['AMM'], + samples: 1, + asOf: amm.timestamp ? amm.timestamp.toISOString() : now.toISOString(), + freshnessSeconds: poolFreshnessSeconds, + }, + } + } + + // Case D: Both venues have liquidity + const ammFullOutput = calculateAMMOutput(amm!.rA, amm!.rB, amount, amm!.feeBp) + const ammFullPrice = ammFullOutput / amount + const ammSpotPrice = amm!.spotPrice + + const sdexFullOutput = amount * sdexFullPrice + + const ammFullSlippage = + ammSpotPrice > 0 + ? Math.max(0, ((ammSpotPrice - ammFullPrice) / ammSpotPrice) * 100) + : 0 + const sdexFullSlippage = + ammSpotPrice > 0 + ? Math.max(0, ((ammSpotPrice - sdexFullPrice) / ammSpotPrice) * 100) + : 0 + + const winningSingleVenue: 'SDEX' | 'AMM' = + sdexFullOutput >= ammFullOutput ? 'SDEX' : 'AMM' + const singleVenueOutput = Math.max(sdexFullOutput, ammFullOutput) + const singleVenuePrice = singleVenueOutput / amount + const singleVenueSlippage = + winningSingleVenue === 'SDEX' ? sdexFullSlippage : ammFullSlippage + + // Candidate proportions for SDEX (with the remaining portion directed to AMM) + const candidateFractions = [ + 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, + 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95 + ] + + // Query SDEX quotes for candidate fractions in parallel + const candidateResults = await Promise.all( + candidateFractions.map(async (pSdex) => { + const aSdex = amount * pSdex + const aAmm = amount - aSdex + const [priceSdex] = await Promise.all([ + fetchSDEXPrice(assetA, assetB, aSdex, network), + ]) + const outSdex = aSdex * priceSdex + const outAmm = calculateAMMOutput(amm!.rA, amm!.rB, aAmm, amm!.feeBp) + const totalOutput = outSdex + outAmm + return { + pSdex, + pAmm: 1 - pSdex, + aSdex, + aAmm, + priceSdex, + outSdex, + outAmm, + totalOutput, + } + }) + ) + + let best = candidateResults[0] + for (const c of candidateResults) { + if (c.totalOutput > best.totalOutput) { + best = c + } + } + + // Splitting is recommended ONLY when it strictly beats the best single-venue route + const improvement = best.totalOutput - singleVenueOutput + const improvementPct = (improvement / singleVenueOutput) * 100 + + // Require measurable improvement (> 0.01% or > 1e-6 units) + const splittingWins = improvement > 1e-6 && improvementPct > 0.01 + + const provenance: SplitProvenance = { + venues: ['SDEX', 'AMM'], + samples: candidateFractions.length + 1, + asOf: now.toISOString(), + freshnessSeconds: poolFreshnessSeconds, + } + + if (!splittingWins) { + // When splitting does not beat the single-venue route, proportions are NOT returned + return { + splitRecommended: false, + assetA: assetA.code, + assetB: assetB.code, + pairKey, + network, + amount, + reason: 'single_venue_better', + message: `Routing entirely to ${winningSingleVenue} yields higher output than splitting across venues`, + singleVenue: { + route: winningSingleVenue, + output: singleVenueOutput, + price: singleVenuePrice, + slippagePct: singleVenueSlippage, + }, + comparison: { + singleVenueRoute: winningSingleVenue, + singleVenueOutput, + singleVenueEffectivePrice: singleVenuePrice, + singleVenueSlippagePct: singleVenueSlippage, + outputImprovement: 0, + outputImprovementPct: 0, + }, + provenance, + } + } + + const effectivePrice = best.totalOutput / amount + const splitSlippagePct = + ammSpotPrice > 0 + ? Math.max(0, ((ammSpotPrice - effectivePrice) / ammSpotPrice) * 100) + : 0 + + const allocations: SplitVenueAllocation[] = [ + { + venue: 'SDEX', + proportion: parseFloat(best.pSdex.toFixed(4)), + amount: parseFloat(best.aSdex.toFixed(7)), + estimatedOutput: parseFloat(best.outSdex.toFixed(7)), + effectivePrice: parseFloat((best.outSdex / best.aSdex).toFixed(7)), + }, + { + venue: 'AMM', + proportion: parseFloat(best.pAmm.toFixed(4)), + amount: parseFloat(best.aAmm.toFixed(7)), + estimatedOutput: parseFloat(best.outAmm.toFixed(7)), + effectivePrice: parseFloat((best.outAmm / best.aAmm).toFixed(7)), + }, + ] + + const comparison: SplitComparison = { + singleVenueRoute: winningSingleVenue, + singleVenueOutput: parseFloat(singleVenueOutput.toFixed(7)), + singleVenueEffectivePrice: parseFloat(singleVenuePrice.toFixed(7)), + singleVenueSlippagePct: parseFloat(singleVenueSlippage.toFixed(4)), + outputImprovement: parseFloat(improvement.toFixed(7)), + outputImprovementPct: parseFloat(improvementPct.toFixed(4)), + } + + return { + splitRecommended: true, + assetA: assetA.code, + assetB: assetB.code, + pairKey, + network, + amount, + message: `Splitting across SDEX and AMM yields ${improvementPct.toFixed(2)}% better output than single-venue route (${winningSingleVenue})`, + proportions: { + SDEX: parseFloat(best.pSdex.toFixed(4)), + AMM: parseFloat(best.pAmm.toFixed(4)), + }, + allocations, + estimatedOutput: parseFloat(best.totalOutput.toFixed(7)), + effectivePrice: parseFloat(effectivePrice.toFixed(7)), + slippagePct: parseFloat(splitSlippagePct.toFixed(4)), + singleVenue: { + route: winningSingleVenue, + output: parseFloat(singleVenueOutput.toFixed(7)), + price: parseFloat(singleVenuePrice.toFixed(7)), + slippagePct: parseFloat(singleVenueSlippage.toFixed(4)), + }, + comparison, + provenance, + } +} diff --git a/src/api/rest.ts b/src/api/rest.ts index edc76157..b5bc0155 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -3,6 +3,7 @@ import { price_requests_total } from '../metrics' import { getCachedPrice, setCachedPrice } from '../redis' import { getAggregatedPrice } from '../aggregator/vwap' import { getBestRoute } from '../aggregator/bestRoute' +import { getSplitRecommendation } from '../aggregator/splitRoute' import { pgPool } from '../db' import { config, getNetworkConfig, activeNetwork, type NetworkName } from '../config' import { isAdminConfigured } from './auth' @@ -12,6 +13,7 @@ import { statusErrorResponseSchema, priceResponseSchema, routeResponseSchema, + splitResponseSchema, historyResponseSchema, poolsResponseSchema, depthResponseSchema, @@ -271,6 +273,29 @@ export async function registerRESTRoutes(app: FastifyInstance) { } ) + // GET /price/:assetA/:assetB/split?amount=1000 + app.get<{ + Params: { assetA: string; assetB: string } + Querystring: { amount?: string; network?: NetworkName } + }>( + '/price/:assetA/:assetB/split', + { schema: { response: { 200: splitResponseSchema } } }, + async (req, reply) => { + const { assetA, assetB } = req.params + const amount = parseFloat(req.query.amount ?? '1000') + const network = req.network ?? activeNetwork + const pair = findPair(assetA, assetB, network) + if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) + if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' }) + + const result = await getSplitRecommendation(pair.assetA, pair.assetB, pair.pairKey, amount, network) + if (result.error) { + return reply.status(404).send({ error: result.error }) + } + return result + } + ) + // GET /price/:assetA/:assetB/history?window=1h&limit=100 app.get<{ Params: { assetA: string; assetB: string }