From c889e70e1e68992447e71b90d5978169c94caad1 Mon Sep 17 00:00:00 2001 From: Bigclavsfx Date: Tue, 20 Oct 2026 14:20:24 +0100 Subject: [PATCH] feat(aggregator): multi-hop swap routing through configurable intermediate assets - Evaluate multi-hop paths across configurable intermediates (ROUTING_INTERMEDIATES, defaulting to XLM and Circle USDC) up to ROUTING_MAX_HOPS depth bound - Detail each hop with venue (SDEX/AMM), poolId, price, spot price, and slippage percentage - Provide side-by-side comparison between direct and multi-hop routes, preferring direct when superior - Include provenance metadata with query timestamps, network, venues queried, sample counts, and data freshness age - Expose network parameter on GET /price/:assetA/:assetB/route and declare schema updates in openapi.yaml and schema.graphql - Add unit and property test coverage verifying multi-hop route selection on thin pairs --- .changeset/multi-hop-routing.md | 11 + .env.example | 9 + openapi.json | 186 ++++++++++++++ openapi.yaml | 98 +++++++- schema.graphql | 1 + src/__tests__/bestRoute.test.ts | 188 +++++++++++++- src/aggregator/bestRoute.ts | 419 ++++++++++++++++++++++++++++---- src/api/graphql.ts | 1 + src/api/rest.ts | 2 +- src/api/schemas.ts | 97 +++++++- src/config.ts | 34 +++ src/types.ts | 43 +++- 12 files changed, 1040 insertions(+), 49 deletions(-) create mode 100644 .changeset/multi-hop-routing.md diff --git a/.changeset/multi-hop-routing.md b/.changeset/multi-hop-routing.md new file mode 100644 index 00000000..a795ff83 --- /dev/null +++ b/.changeset/multi-hop-routing.md @@ -0,0 +1,11 @@ +--- +'lens': minor +--- + +Multi-hop routing through configurable intermediate assets bounded in depth. + +- Evaluates multi-hop routes (up to `ROUTING_MAX_HOPS`, defaulting to 3) across configurable intermediate assets (`ROUTING_INTERMEDIATES`, defaulting to XLM and Circle USDC). +- Names each hop with its input/output assets, execution venue (`SDEX` | `AMM`), pool ID if applicable, price, spot price, and slippage percentage. +- Shows side-by-side comparison between direct routes and multi-hop paths, ensuring direct routes win when they produce higher output. +- Returns comprehensive execution provenance including sample timestamps, target network, queried venues, total sample count, and data freshness age in seconds. +- Adds test coverage verifying thin pairs where two hops beat a direct trade. diff --git a/.env.example b/.env.example index a23c6be6..b7787fee 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,9 @@ REFLECTOR_CONTRACT_ID_TESTNET= # Comma-separated pairs to watch on testnet. # Format: "CODE:ISSUER/CODE:ISSUER". Use "native" for XLM. WATCHED_PAIRS_TESTNET=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 +# Multi-hop routing configuration for testnet +ROUTING_MAX_HOPS_TESTNET=3 +ROUTING_INTERMEDIATES_TESTNET=XLM:native,USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 # --- Stellar Network — Mainnet --- HORIZON_URL_MAINNET=https://horizon.stellar.org @@ -99,6 +102,9 @@ AQUARIUS_ENABLED_MAINNET=true REFLECTOR_CONTRACT_ID_MAINNET= # Comma-separated pairs to watch on mainnet. WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN +# Multi-hop routing configuration for mainnet +ROUTING_MAX_HOPS_MAINNET=3 +ROUTING_INTERMEDIATES_MAINNET=XLM:native,USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN # --- Venue endpoint overrides (optional, shared across networks unless a # paired _TESTNET / _MAINNET variant is set) --- @@ -115,6 +121,9 @@ RPC_URL=https://soroban-testnet.stellar.org NETWORK_PASSPHRASE=Test SDF Network ; September 2015 SOROSWAP_FACTORY_ADDRESS=CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY WATCHED_PAIRS=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 +# Routing configuration fallbacks +ROUTING_MAX_HOPS=3 +ROUTING_INTERMEDIATES=XLM:native,USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 # Reflector oracle contract ID on testnet (falls back from paired REFLECTOR_CONTRACT_ID_TESTNET) REFLECTOR_CONTRACT_ID= diff --git a/openapi.json b/openapi.json index b6c26090..57dad526 100644 --- a/openapi.json +++ b/openapi.json @@ -2899,8 +2899,174 @@ } } }, + "RouteHop": { + "type": "object", + "required": [ + "assetIn", + "assetOut", + "venue", + "inputAmount", + "outputAmount", + "price", + "spotPrice", + "slippagePct" + ], + "properties": { + "assetIn": { + "$ref": "#/components/schemas/Asset" + }, + "assetOut": { + "$ref": "#/components/schemas/Asset" + }, + "venue": { + "type": "string", + "enum": [ + "SDEX", + "AMM" + ] + }, + "poolId": { + "type": "string" + }, + "inputAmount": { + "type": "number" + }, + "outputAmount": { + "type": "number" + }, + "price": { + "type": "number" + }, + "spotPrice": { + "type": "number" + }, + "slippagePct": { + "type": "number" + } + } + }, + "RouteOption": { + "type": "object", + "required": [ + "hops", + "hopCount", + "estimatedOutput", + "effectivePrice", + "slippagePct" + ], + "properties": { + "hops": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RouteHop" + } + }, + "hopCount": { + "type": "integer" + }, + "estimatedOutput": { + "type": "number" + }, + "effectivePrice": { + "type": "number" + }, + "slippagePct": { + "type": "number" + } + } + }, + "RouteComparison": { + "type": "object", + "required": [ + "direct", + "multiHop" + ], + "properties": { + "direct": { + "type": "object", + "required": [ + "route", + "sdexPrice", + "ammPrice", + "estimatedOutput", + "slippagePct" + ], + "properties": { + "route": { + "type": "string", + "enum": [ + "SDEX", + "AMM", + "SPLIT", + "UNKNOWN" + ] + }, + "sdexPrice": { + "type": "number" + }, + "ammPrice": { + "type": "number" + }, + "estimatedOutput": { + "type": "number" + }, + "slippagePct": { + "type": "number" + } + } + }, + "multiHop": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/RouteOption" + } + ] + } + } + }, + "RouteProvenance": { + "type": "object", + "required": [ + "sampledAt", + "network", + "venuesQueried", + "sampleCount", + "freshnessSeconds" + ], + "properties": { + "sampledAt": { + "type": "string", + "format": "date-time" + }, + "network": { + "type": "string" + }, + "venuesQueried": { + "type": "array", + "items": { + "type": "string" + } + }, + "sampleCount": { + "type": "integer" + }, + "freshnessSeconds": { + "type": "number", + "nullable": true + } + } + }, "RouteInfo": { "type": "object", + "required": [ + "route", + "sdexPrice", + "ammPrice", + "estimatedOutput", + "slippagePct", + "recommendation" + ], "properties": { "route": { "type": "string", @@ -2908,6 +3074,7 @@ "SDEX", "AMM", "SPLIT", + "MULTI_HOP", "UNKNOWN" ] }, @@ -2925,6 +3092,25 @@ }, "recommendation": { "type": "string" + }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, + "hops": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RouteHop" + } + }, + "comparison": { + "$ref": "#/components/schemas/RouteComparison" + }, + "provenance": { + "$ref": "#/components/schemas/RouteProvenance" } } }, diff --git a/openapi.yaml b/openapi.yaml index 6dffdbac..de97043b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1931,12 +1931,97 @@ components: type: integer nullable: true + RouteHop: + type: object + required: [assetIn, assetOut, venue, inputAmount, outputAmount, price, spotPrice, slippagePct] + properties: + assetIn: + $ref: '#/components/schemas/Asset' + assetOut: + $ref: '#/components/schemas/Asset' + venue: + type: string + enum: [SDEX, AMM] + poolId: + type: string + inputAmount: + type: number + outputAmount: + type: number + price: + type: number + spotPrice: + type: number + slippagePct: + type: number + + RouteOption: + type: object + required: [hops, hopCount, estimatedOutput, effectivePrice, slippagePct] + properties: + hops: + type: array + items: + $ref: '#/components/schemas/RouteHop' + hopCount: + type: integer + estimatedOutput: + type: number + effectivePrice: + type: number + slippagePct: + type: number + + RouteComparison: + type: object + required: [direct, multiHop] + properties: + direct: + type: object + required: [route, sdexPrice, ammPrice, estimatedOutput, slippagePct] + properties: + route: + type: string + enum: [SDEX, AMM, SPLIT, UNKNOWN] + sdexPrice: + type: number + ammPrice: + type: number + estimatedOutput: + type: number + slippagePct: + type: number + multiHop: + nullable: true + allOf: + - $ref: '#/components/schemas/RouteOption' + + RouteProvenance: + type: object + required: [sampledAt, network, venuesQueried, sampleCount, freshnessSeconds] + properties: + sampledAt: + type: string + format: date-time + network: + type: string + venuesQueried: + type: array + items: + type: string + sampleCount: + type: integer + freshnessSeconds: + type: number + nullable: true + RouteInfo: type: object + required: [route, sdexPrice, ammPrice, estimatedOutput, slippagePct, recommendation] properties: route: type: string - enum: [SDEX, AMM, SPLIT, UNKNOWN] + enum: [SDEX, AMM, SPLIT, MULTI_HOP, UNKNOWN] sdexPrice: type: number ammPrice: @@ -1947,6 +2032,17 @@ components: type: number recommendation: type: string + network: + type: string + enum: [testnet, mainnet] + hops: + type: array + items: + $ref: '#/components/schemas/RouteHop' + comparison: + $ref: '#/components/schemas/RouteComparison' + provenance: + $ref: '#/components/schemas/RouteProvenance' PriceBucket: type: object diff --git a/schema.graphql b/schema.graphql index 68955141..061ef80c 100644 --- a/schema.graphql +++ b/schema.graphql @@ -27,6 +27,7 @@ type RouteInfo { estimatedOutput: Float! slippagePct: Float! recommendation: String! + network: String } type PriceBucket { diff --git a/src/__tests__/bestRoute.test.ts b/src/__tests__/bestRoute.test.ts index 5dd1b5c9..b27c9cd9 100644 --- a/src/__tests__/bestRoute.test.ts +++ b/src/__tests__/bestRoute.test.ts @@ -13,19 +13,20 @@ vi.mock('../db', () => ({ vi.mock('@stellar/stellar-sdk', async (importOriginal) => { const actual = await importOriginal() const callFn = vi.fn() + const strictSendPathsFn = vi.fn().mockReturnThis() return { ...actual, Horizon: { Server: vi.fn(function() { return { - strictSendPaths: vi.fn().mockReturnThis(), + strictSendPaths: strictSendPathsFn, call: callFn } }) }, Asset: Object.assign( vi.fn(function(code, issuer) { return { code, issuer } }), - { native: vi.fn(() => 'native') } + { native: vi.fn(() => ({ code: 'XLM', issuer: null })) } ), // config.ts's buildNetworkConfig() falls back to these when no // NETWORK_PASSPHRASE_* env var is set — needed now that getBestRoute @@ -34,7 +35,8 @@ vi.mock('@stellar/stellar-sdk', async (importOriginal) => { PUBLIC: 'Public Global Stellar Network ; September 2015', TESTNET: 'Test SDF Network ; September 2015', }, - __mockCall: callFn + __mockCall: callFn, + __strictSendPaths: strictSendPathsFn, } }) @@ -45,9 +47,11 @@ describe('getBestRoute', () => { const mockQuery = vi.mocked(pgPool.query) const mockCall = (StellarSdk as any).__mockCall + const mockStrictSendPaths = (StellarSdk as any).__strictSendPaths beforeEach(() => { vi.clearAllMocks() + mockStrictSendPaths.mockReturnThis() // horizonServers is memoised at module scope (see bestRoute.ts) — clear // between tests so each one observes fresh Horizon.Server() constructions. _resetHorizonServers() @@ -257,4 +261,182 @@ describe('getBestRoute', () => { expect(r.slippagePct).toBe(0) }) }) + + describe('multi-hop routing', () => { + const thinPairAssetA = { code: 'MEME', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' } + const thinPairAssetB = { code: 'RARE', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' } + const intermediateXLM = { code: 'XLM', issuer: null } + const thinDirectKey = 'MEME:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/RARE:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' + + it('selects a two-hop route when intermediate routing yields more output than direct', async () => { + // Direct thin pair: + // SDEX direct gives only 10 RARE for 100 MEME + // AMM direct has no pool + // Hop 1 (MEME -> XLM): + // SDEX gives 200 XLM for 100 MEME + // Hop 2 (XLM -> RARE): + // SDEX gives 500 RARE for 200 XLM + // Total 2-hop output = 500 RARE vs direct 10 RARE + mockStrictSendPaths.mockImplementation((sourceAsset: any, amountStr: string, destArray: any[]) => { + const dest = destArray?.[0] + const amount = parseFloat(amountStr) + + if (dest?.code === 'RARE' && amount === 100) { + mockCall.mockResolvedValueOnce({ + records: [{ destination_amount: '10' }], + }) + } else if (dest?.code === 'XLM' || (dest === 'native' || !dest?.issuer)) { + mockCall.mockResolvedValueOnce({ + records: [{ destination_amount: String(amount * 2) }], // 100 * 2 = 200 XLM + }) + } else if (dest?.code === 'RARE' && amount === 200) { + mockCall.mockResolvedValueOnce({ + records: [{ destination_amount: '500' }], // 200 XLM -> 500 RARE + }) + } else { + mockCall.mockResolvedValueOnce({ records: [] }) + } + return { call: mockCall } + }) + + mockQuery.mockResolvedValue({ rows: [] } as any) // No AMM pools + + const result = await getBestRoute( + thinPairAssetA, + thinPairAssetB, + thinDirectKey, + 100, + 'testnet', + { + maxHops: 2, + intermediates: [intermediateXLM], + } + ) + + expect(result.route).toBe('MULTI_HOP') + expect(result.estimatedOutput).toBe(500) + expect(result.hops).toBeDefined() + expect(result.hops?.length).toBe(2) + + // Hop 1 + expect(result.hops![0].assetIn.code).toBe('MEME') + expect(result.hops![0].assetOut.code).toBe('XLM') + expect(result.hops![0].venue).toBe('SDEX') + expect(result.hops![0].inputAmount).toBe(100) + expect(result.hops![0].outputAmount).toBe(200) + expect(result.hops![0].slippagePct).toBe(0) + + // Hop 2 + expect(result.hops![1].assetIn.code).toBe('XLM') + expect(result.hops![1].assetOut.code).toBe('RARE') + expect(result.hops![1].venue).toBe('SDEX') + expect(result.hops![1].inputAmount).toBe(200) + expect(result.hops![1].outputAmount).toBe(500) + expect(result.hops![1].slippagePct).toBe(0) + + // Comparison with direct + expect(result.comparison).toBeDefined() + expect(result.comparison?.direct.estimatedOutput).toBe(10) + expect(result.comparison?.direct.route).toBe('SDEX') + expect(result.comparison?.multiHop?.estimatedOutput).toBe(500) + expect(result.comparison?.multiHop?.hopCount).toBe(2) + + // Provenance + expect(result.provenance).toBeDefined() + expect(result.provenance?.network).toBe('testnet') + expect(result.provenance?.venuesQueried).toEqual(['SDEX', 'AMM']) + expect(result.provenance?.sampleCount).toBeGreaterThan(0) + }) + + it('keeps direct route when direct is better than multi-hop', async () => { + // Direct gives 1000 + // Multi-hop gives only 800 + mockStrictSendPaths.mockImplementation((sourceAsset: any, amountStr: string, destArray: any[]) => { + const dest = destArray?.[0] + const amount = parseFloat(amountStr) + + if (dest?.code === 'RARE' && amount === 100) { + mockCall.mockResolvedValueOnce({ + records: [{ destination_amount: '1000' }], + }) + } else if (dest?.code === 'XLM' || (dest === 'native' || !dest?.issuer)) { + mockCall.mockResolvedValueOnce({ + records: [{ destination_amount: String(amount * 2) }], + }) + } else if (dest?.code === 'RARE' && amount === 200) { + mockCall.mockResolvedValueOnce({ + records: [{ destination_amount: '800' }], + }) + } else { + mockCall.mockResolvedValueOnce({ records: [] }) + } + return { call: mockCall } + }) + + mockQuery.mockResolvedValue({ rows: [] } as any) + + const result = await getBestRoute( + thinPairAssetA, + thinPairAssetB, + thinDirectKey, + 100, + 'testnet', + { + maxHops: 2, + intermediates: [intermediateXLM], + } + ) + + expect(result.route).toBe('SDEX') + expect(result.estimatedOutput).toBe(1000) + expect(result.comparison).toBeDefined() + expect(result.comparison?.direct.estimatedOutput).toBe(1000) + expect(result.comparison?.multiHop?.estimatedOutput).toBe(800) + expect(result.hops?.length).toBe(1) + expect(result.hops![0].venue).toBe('SDEX') + }) + + it('respects configurable maxHops depth bound', async () => { + // If maxHops = 1, multi-hop should not be considered even if intermediates are passed + mockStrictSendPaths.mockImplementation((sourceAsset: any, amountStr: string, destArray: any[]) => { + const dest = destArray?.[0] + const amount = parseFloat(amountStr) + + if (dest?.code === 'RARE' && amount === 100) { + mockCall.mockResolvedValueOnce({ + records: [{ destination_amount: '10' }], + }) + } else if (dest?.code === 'XLM' || (dest === 'native' || !dest?.issuer)) { + mockCall.mockResolvedValueOnce({ + records: [{ destination_amount: '200' }], + }) + } else if (dest?.code === 'RARE' && amount === 200) { + mockCall.mockResolvedValueOnce({ + records: [{ destination_amount: '500' }], + }) + } else { + mockCall.mockResolvedValueOnce({ records: [] }) + } + return { call: mockCall } + }) + + mockQuery.mockResolvedValue({ rows: [] } as any) + + const result = await getBestRoute( + thinPairAssetA, + thinPairAssetB, + thinDirectKey, + 100, + 'testnet', + { + maxHops: 1, // depth limit 1 hop (direct only) + intermediates: [intermediateXLM], + } + ) + + expect(result.route).toBe('SDEX') + expect(result.estimatedOutput).toBe(10) + expect(result.comparison?.multiHop).toBeNull() + }) + }) }) diff --git a/src/aggregator/bestRoute.ts b/src/aggregator/bestRoute.ts index e9ee02cb..fe2cb44a 100644 --- a/src/aggregator/bestRoute.ts +++ b/src/aggregator/bestRoute.ts @@ -1,7 +1,7 @@ import { Asset } from '@stellar/stellar-sdk' -import { activeNetwork, type NetworkName } from '../config' +import { activeNetwork, getNetworkConfig, type NetworkName } from '../config' import { getHorizonServer, resetNetworkClients } from '../network/clients' -import type { AssetId, RouteInfo } from '../types' +import type { AssetId, RouteInfo, RouteHop, RouteOption, RouteProvenance } from '../types' import { pgPool } from '../db' import { calculateAMMSpotPrice } from '../pricing/depth' @@ -10,18 +10,37 @@ function assetIdToStellar(asset: AssetId) { return new Asset(asset.code, asset.issuer) } +export function assetEquals(a: AssetId, b: AssetId): boolean { + if (a.code.toUpperCase() !== b.code.toUpperCase()) return false + const aIss = a.issuer ?? null + const bIss = b.issuer ?? null + return aIss === bIss +} + +export function makePairKey(a: AssetId, b: AssetId): string { + const aStr = a.issuer ? `${a.code}:${a.issuer}` : a.code + const bStr = b.issuer ? `${b.code}:${b.issuer}` : b.code + return [aStr, bStr].sort().join('/') +} + +interface AMMPoolInfo { + poolId: string + reserveA: number + reserveB: number + feeBp: number + timestamp?: Date +} + // AMM pricing is per-network on both legs of the lookup: the pool_snapshots // scan and the price_points subquery that names which pools hold this pair. // Pool ids are only unique within a network, so filtering just the outer scan // would happily price a mainnet pair off a testnet pool's reserves. -async function getAMMPrice( +async function getAMMPool( pairKey: string, - amount: number, network: NetworkName -): Promise<{ price: number; spotPrice: number }> { - // Get latest pool snapshot via pool_id (pairKey indexes price_points correctly) +): Promise { const result = await pgPool.query( - `SELECT DISTINCT ON (ps.pool_id) ps.reserve_a, ps.reserve_b, ps.fee_bp + `SELECT DISTINCT ON (ps.pool_id) 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 ( @@ -32,19 +51,34 @@ async function getAMMPrice( LIMIT 1`, [pairKey, network] ) - if (!result.rows[0]) return { price: 0, spotPrice: 0 } + if (!result.rows[0]) return null + + const { pool_id, reserve_a, reserve_b, fee_bp, timestamp } = result.rows[0] + return { + poolId: pool_id, + reserveA: parseFloat(reserve_a), + reserveB: parseFloat(reserve_b), + feeBp: parseInt(fee_bp, 10), + timestamp: timestamp ? new Date(timestamp) : undefined, + } +} - const { reserve_a, reserve_b, fee_bp } = result.rows[0] - const rA = parseFloat(reserve_a) - const rB = parseFloat(reserve_b) - const fee = 1 - (parseInt(fee_bp) / 10000) +async function getAMMPrice( + pairKey: string, + amount: number, + network: NetworkName +): Promise<{ price: number; spotPrice: number; output: number; poolId?: string; timestamp?: Date }> { + const pool = await getAMMPool(pairKey, network) + if (!pool || amount <= 0) return { price: 0, spotPrice: 0, output: 0 } + const fee = 1 - pool.feeBp / 10000 // Constant product formula: output = (reserveB * amount * fee) / (reserveA + amount * fee) const effectiveInput = amount * fee - const output = (rB * effectiveInput) / (rA + effectiveInput) - // spotPrice is the reserve-ratio marginal price (no size, no fee); price is - // the average execution price for `amount` on the constant-product curve. - return { price: output / amount, spotPrice: calculateAMMSpotPrice(rA, rB) } + const output = (pool.reserveB * effectiveInput) / (pool.reserveA + effectiveInput) + const price = output / amount + const spotPrice = calculateAMMSpotPrice(pool.reserveA, pool.reserveB) + + return { price, spotPrice, output, poolId: pool.poolId, timestamp: pool.timestamp } } /** @@ -61,40 +95,49 @@ async function getSDEXPrice( assetB: AssetId, amount: number, network: NetworkName -): Promise { +): Promise<{ price: number; output: number; recordCount: number }> { 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 + if (!paths.records || paths.records.length === 0) return { price: 0, output: 0, recordCount: 0 } const best = paths.records[0] - return parseFloat(best.destination_amount) / amount + const destAmount = parseFloat(best.destination_amount) + return { + price: destAmount / amount, + output: destAmount, + recordCount: paths.records.length, + } } catch (err) { - return 0 + return { price: 0, output: 0, recordCount: 0 } } } -export async function getBestRoute( - assetA: AssetId, - assetB: AssetId, - pairKey: string, - amount: number = 1000, - network: NetworkName = activeNetwork -): Promise { - const [sdexPrice, amm] = await Promise.all([ - getSDEXPrice(assetA, assetB, amount, network), - getAMMPrice(pairKey, amount, network), - ]) - const ammPrice = amm.price +export interface DirectRouteResult { + route: 'SDEX' | 'AMM' | 'SPLIT' | 'UNKNOWN' + sdexPrice: number + ammPrice: number + estimatedOutput: number + slippagePct: number + recommendation: string +} - let route: RouteInfo['route'] = 'UNKNOWN' +function computeDirectRoute( + sdexPrice: number, + amm: { price: number; spotPrice: number }, + amount: number +): DirectRouteResult { + const ammPrice = amm.price + let route: DirectRouteResult['route'] = 'UNKNOWN' let estimatedOutput = 0 let recommendation = 'Insufficient liquidity data' if (sdexPrice === 0 && ammPrice === 0) { - throw new Error("No pricing data available") + route = 'UNKNOWN' + estimatedOutput = 0 + recommendation = 'Insufficient liquidity data' } else if (sdexPrice === 0) { route = 'AMM' estimatedOutput = ammPrice * amount @@ -121,14 +164,6 @@ export async function getBestRoute( } } - // Slippage is the shortfall of the execution price against the spot of the - // venue actually being traded on. For an AMM route that is the pool's - // reserve-ratio price, which does not depend on trade size (the old code - // compared the execution price with itself, so it was always 0). SDEX and - // SPLIT routes have no size-independent reference, so they report 0 — the - // AMM's spot is a different venue and would turn a cross-venue spread into - // a made-up slippage figure. An execution price at or above spot is not - // slippage, hence the clamp. const spotPrice = route === 'AMM' ? amm.spotPrice : 0 const executionPrice = amount > 0 ? estimatedOutput / amount : 0 const slippagePct = @@ -136,3 +171,303 @@ export async function getBestRoute( return { route, sdexPrice, ammPrice, estimatedOutput, slippagePct, recommendation } } + +async function simulateHop( + assetIn: AssetId, + assetOut: AssetId, + inputAmount: number, + network: NetworkName +): Promise<{ hop: RouteHop; poolTimestamp?: Date; sdexSampleCount: number; ammSampleCount: number } | null> { + const pairKey = makePairKey(assetIn, assetOut) + const [sdex, amm] = await Promise.all([ + getSDEXPrice(assetIn, assetOut, inputAmount, network), + getAMMPrice(pairKey, inputAmount, network), + ]) + + const sdexOutput = sdex.output + const ammOutput = amm.output + + if (sdexOutput === 0 && ammOutput === 0) { + return null + } + + let venue: 'SDEX' | 'AMM' + let outputAmount: number + let price: number + let spotPrice: number + let slippagePct: number + let poolId: string | undefined + + if (ammOutput > sdexOutput) { + venue = 'AMM' + outputAmount = ammOutput + price = amm.price + spotPrice = amm.spotPrice + slippagePct = spotPrice > 0 ? Math.max(0, ((spotPrice - price) / spotPrice) * 100) : 0 + poolId = amm.poolId + } else { + venue = 'SDEX' + outputAmount = sdexOutput + price = sdex.price + spotPrice = 0 + slippagePct = 0 + } + + return { + hop: { + assetIn, + assetOut, + venue, + poolId, + inputAmount, + outputAmount, + price, + spotPrice, + slippagePct, + }, + poolTimestamp: amm.timestamp, + sdexSampleCount: sdex.recordCount, + ammSampleCount: amm.poolId ? 1 : 0, + } +} + +export interface RouteOptionsConfig { + maxHops?: number + intermediates?: AssetId[] +} + +export async function getBestRoute( + assetA: AssetId, + assetB: AssetId, + pairKey: string, + amount: number = 1000, + network: NetworkName = activeNetwork, + routingOverrides?: RouteOptionsConfig +): Promise { + const startTime = new Date() + const netConfig = getNetworkConfig(network) + const maxHops = Math.max(1, routingOverrides?.maxHops ?? netConfig.routing?.maxHops ?? 3) + const configuredIntermediates = routingOverrides?.intermediates ?? netConfig.routing?.intermediates ?? [] + + // Filter intermediates: exclude start asset and destination asset, and deduplicate + const intermediates: AssetId[] = [] + for (const intermediate of configuredIntermediates) { + if (!assetEquals(intermediate, assetA) && !assetEquals(intermediate, assetB)) { + if (!intermediates.some(existing => assetEquals(existing, intermediate))) { + intermediates.push(intermediate) + } + } + } + + // 1. Direct path + const [directSdex, directAmm] = await Promise.all([ + getSDEXPrice(assetA, assetB, amount, network), + getAMMPrice(pairKey, amount, network), + ]) + + const directResult = computeDirectRoute( + directSdex.price, + { price: directAmm.price, spotPrice: directAmm.spotPrice }, + amount + ) + + let totalSampleCount = directSdex.recordCount + (directAmm.poolId ? 1 : 0) + let latestPoolTimestamp: Date | undefined = directAmm.timestamp + + // 2. Multi-hop search up to maxHops (bounded depth >= 2) + let bestMultiHop: RouteOption | null = null + + if (maxHops >= 2 && intermediates.length > 0) { + interface PathState { + currentAsset: AssetId + currentAmount: number + hops: RouteHop[] + visited: AssetId[] + } + + const queue: PathState[] = [ + { + currentAsset: assetA, + currentAmount: amount, + hops: [], + visited: [assetA], + }, + ] + + while (queue.length > 0) { + const state = queue.shift()! + const currentHopDepth = state.hops.length + + if (currentHopDepth >= maxHops) continue + + // For the next hop, consider: + // a) hopping directly to destination assetB if currentHopDepth + 1 >= 2 + // b) hopping to an intermediate if currentHopDepth + 1 < maxHops + + // Candidate next assets: + const nextCandidates: AssetId[] = [] + + // If we've made at least 1 hop, we can finish at assetB + if (currentHopDepth >= 1) { + nextCandidates.push(assetB) + } + + // If we can still hop further, we can also visit remaining unvisited intermediates + if (currentHopDepth + 1 < maxHops) { + for (const inter of intermediates) { + if (!state.visited.some(v => assetEquals(v, inter))) { + nextCandidates.push(inter) + } + } + } + + // Simulate hops to all candidates in parallel + const hopSimulations = await Promise.all( + nextCandidates.map(nextAsset => + simulateHop(state.currentAsset, nextAsset, state.currentAmount, network) + ) + ) + + for (let i = 0; i < nextCandidates.length; i++) { + const nextAsset = nextCandidates[i] + const sim = hopSimulations[i] + if (!sim) continue + + totalSampleCount += sim.sdexSampleCount + sim.ammSampleCount + if (sim.poolTimestamp) { + if (!latestPoolTimestamp || sim.poolTimestamp > latestPoolTimestamp) { + latestPoolTimestamp = sim.poolTimestamp + } + } + + const newHops = [...state.hops, sim.hop] + + if (assetEquals(nextAsset, assetB)) { + // Completed path to destination + const output = sim.hop.outputAmount + const effectivePrice = output / amount + // Overall slippage compounded across hops + const overallSlippagePct = + (1 - newHops.reduce((acc, h) => acc * (1 - h.slippagePct / 100), 1)) * 100 + + const option: RouteOption = { + hops: newHops, + hopCount: newHops.length, + estimatedOutput: output, + effectivePrice, + slippagePct: Math.max(0, overallSlippagePct), + } + + if (!bestMultiHop || option.estimatedOutput > bestMultiHop.estimatedOutput) { + bestMultiHop = option + } + } else { + // Intermediate step + queue.push({ + currentAsset: nextAsset, + currentAmount: sim.hop.outputAmount, + hops: newHops, + visited: [...state.visited, nextAsset], + }) + } + } + } + } + + // 3. Compare direct and multi-hop + const hasDirectLiquidity = directResult.route !== 'UNKNOWN' && directResult.estimatedOutput > 0 + const hasMultiHopLiquidity = bestMultiHop !== null && bestMultiHop.estimatedOutput > 0 + + if (!hasDirectLiquidity && !hasMultiHopLiquidity) { + throw new Error('No pricing data available') + } + + const comparison = { + direct: { + route: directResult.route, + sdexPrice: directResult.sdexPrice, + ammPrice: directResult.ammPrice, + estimatedOutput: directResult.estimatedOutput, + slippagePct: directResult.slippagePct, + }, + multiHop: bestMultiHop, + } + + const now = new Date() + const freshnessSeconds = latestPoolTimestamp + ? Math.max(0, Math.round((now.getTime() - latestPoolTimestamp.getTime()) / 1000)) + : null + + const provenance: RouteProvenance = { + sampledAt: now.toISOString(), + network, + venuesQueried: ['SDEX', 'AMM'], + sampleCount: totalSampleCount, + freshnessSeconds, + } + + // Does multi-hop beat direct? + if (bestMultiHop && (!hasDirectLiquidity || bestMultiHop.estimatedOutput > directResult.estimatedOutput)) { + const hopSummary = bestMultiHop.hops + .map(h => `${h.assetIn.code}→${h.assetOut.code} via ${h.venue} (${h.slippagePct.toFixed(2)}% slip)`) + .join(', ') + + let rec: string + if (hasDirectLiquidity && directResult.estimatedOutput > 0) { + const advantagePct = + ((bestMultiHop.estimatedOutput - directResult.estimatedOutput) / directResult.estimatedOutput) * 100 + rec = `${bestMultiHop.hopCount}-hop route (${hopSummary}) is ${advantagePct.toFixed(2)}% better than direct` + } else { + rec = `${bestMultiHop.hopCount}-hop route (${hopSummary}) available (no direct liquidity)` + } + + return { + route: 'MULTI_HOP', + sdexPrice: directResult.sdexPrice, + ammPrice: directResult.ammPrice, + estimatedOutput: bestMultiHop.estimatedOutput, + slippagePct: bestMultiHop.slippagePct, + recommendation: rec, + network, + hops: bestMultiHop.hops, + comparison, + provenance, + } + } + + // Direct route wins + const directHops: RouteHop[] = [] + if (directResult.route === 'AMM' || directResult.route === 'SDEX') { + directHops.push({ + assetIn: assetA, + assetOut: assetB, + venue: directResult.route, + poolId: directAmm.poolId, + inputAmount: amount, + outputAmount: directResult.estimatedOutput, + price: directResult.estimatedOutput / amount, + spotPrice: directResult.route === 'AMM' ? directAmm.spotPrice : 0, + slippagePct: directResult.slippagePct, + }) + } + + let finalRecommendation = directResult.recommendation + if (bestMultiHop && bestMultiHop.estimatedOutput > 0 && directResult.estimatedOutput > 0) { + const diffPct = + ((directResult.estimatedOutput - bestMultiHop.estimatedOutput) / bestMultiHop.estimatedOutput) * 100 + finalRecommendation = `${directResult.recommendation} (direct route is ${diffPct.toFixed(2)}% better than ${bestMultiHop.hopCount}-hop)` + } + + return { + route: directResult.route, + sdexPrice: directResult.sdexPrice, + ammPrice: directResult.ammPrice, + estimatedOutput: directResult.estimatedOutput, + slippagePct: directResult.slippagePct, + recommendation: finalRecommendation, + network, + hops: directHops, + comparison, + provenance, + } +} diff --git a/src/api/graphql.ts b/src/api/graphql.ts index a32a7a7b..874b0ebe 100644 --- a/src/api/graphql.ts +++ b/src/api/graphql.ts @@ -93,6 +93,7 @@ export const schema = ` estimatedOutput: Float! slippagePct: Float! recommendation: String! + network: String } type PriceBucket { diff --git a/src/api/rest.ts b/src/api/rest.ts index 0b4f62fa..e5467596 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -142,7 +142,7 @@ export async function registerRESTRoutes(app: FastifyInstance) { // GET /price/:assetA/:assetB/route?amount=1000 app.get<{ Params: { assetA: string; assetB: string } - Querystring: { amount?: string } + Querystring: { amount?: string; network?: string } }>( '/price/:assetA/:assetB/route', { schema: { response: { 200: routeResponseSchema } } }, diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 4c9a2233..861bf9fb 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -137,6 +137,47 @@ export const priceResponseSchema = { }, } as const +/** Hop schema for multi-hop routes */ +const routeHopSchema = { + type: 'object', + required: [ + 'assetIn', + 'assetOut', + 'venue', + 'inputAmount', + 'outputAmount', + 'price', + 'spotPrice', + 'slippagePct', + ], + additionalProperties: false, + properties: { + assetIn: { + type: 'object', + required: ['code'], + properties: { + code: { type: 'string' }, + issuer: { type: ['string', 'null'] }, + }, + }, + assetOut: { + type: 'object', + required: ['code'], + properties: { + code: { type: 'string' }, + issuer: { type: ['string', 'null'] }, + }, + }, + venue: { type: 'string', enum: ['SDEX', 'AMM'] }, + poolId: { type: 'string' }, + inputAmount: { type: 'number' }, + outputAmount: { type: 'number' }, + price: { type: 'number' }, + spotPrice: { type: 'number' }, + slippagePct: { type: 'number' }, + }, +} as const + /** GET /price/:assetA/:assetB/route — matches RouteInfo */ export const routeResponseSchema = { type: 'object', @@ -150,12 +191,66 @@ export const routeResponseSchema = { ], additionalProperties: false, properties: { - route: { type: 'string', enum: ['SDEX', 'AMM', 'SPLIT', 'UNKNOWN'] }, + route: { type: 'string', enum: ['SDEX', 'AMM', 'SPLIT', 'MULTI_HOP', 'UNKNOWN'] }, sdexPrice: { type: 'number' }, ammPrice: { type: 'number' }, estimatedOutput: { type: 'number' }, slippagePct: { type: 'number' }, recommendation: { type: 'string' }, + network: { type: 'string', enum: ['testnet', 'mainnet'] }, + hops: { + type: 'array', + items: routeHopSchema, + }, + comparison: { + type: 'object', + required: ['direct', 'multiHop'], + additionalProperties: false, + properties: { + direct: { + type: 'object', + required: ['route', 'sdexPrice', 'ammPrice', 'estimatedOutput', 'slippagePct'], + additionalProperties: false, + properties: { + route: { type: 'string', enum: ['SDEX', 'AMM', 'SPLIT', 'UNKNOWN'] }, + sdexPrice: { type: 'number' }, + ammPrice: { type: 'number' }, + estimatedOutput: { type: 'number' }, + slippagePct: { type: 'number' }, + }, + }, + multiHop: { + type: ['object', 'null'], + required: ['hops', 'hopCount', 'estimatedOutput', 'effectivePrice', 'slippagePct'], + additionalProperties: false, + properties: { + hops: { + type: 'array', + items: routeHopSchema, + }, + hopCount: { type: 'integer' }, + estimatedOutput: { type: 'number' }, + effectivePrice: { type: 'number' }, + slippagePct: { type: 'number' }, + }, + }, + }, + }, + provenance: { + type: 'object', + required: ['sampledAt', 'network', 'venuesQueried', 'sampleCount', 'freshnessSeconds'], + additionalProperties: false, + properties: { + sampledAt: { type: 'string' }, + network: { type: 'string' }, + venuesQueried: { + type: 'array', + items: { type: 'string' }, + }, + sampleCount: { type: 'integer' }, + freshnessSeconds: { type: ['number', 'null'] }, + }, + }, }, } as const diff --git a/src/config.ts b/src/config.ts index 35c54518..4b312b50 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,6 +44,12 @@ export interface NetworkConfig { } /** Watched asset pairs for this network. */ pairs: WatchedPair[] + routing: { + /** Maximum hop depth for multi-hop routing (default: 3) */ + maxHops: number + /** Configured intermediate assets for multi-hop routing */ + intermediates: AssetId[] + } facilitator: { /** Secret key for the facilitator's fee-paying account */ secretKey?: string @@ -87,6 +93,17 @@ function parseAllowedHosts(raw: string): string[] { const MAINNET_DEFAULT_PAIRS = 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM' +const TESTNET_DEFAULT_INTERMEDIATES = + 'XLM:native,USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' + +const MAINNET_DEFAULT_INTERMEDIATES = + 'XLM:native,USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + +function parseIntermediateAssets(raw: string): AssetId[] { + if (!raw.trim()) return [] + return raw.split(',').map(a => parseAsset(a.trim())) +} + function parseWatchedPairs(raw: string): WatchedPair[] { if (!raw.trim()) return [] return raw.split(',').map(pair => { @@ -217,6 +234,21 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { (network === 'testnet' ? process.env.WATCHED_PAIRS : undefined) || (network === 'mainnet' ? MAINNET_DEFAULT_PAIRS : '') + // ── Multi-hop Routing ─────────────────────────────────────────────────── + const maxHopsRaw = + process.env[`ROUTING_MAX_HOPS_${suffix}`] || + (network === 'testnet' ? process.env.ROUTING_MAX_HOPS : undefined) || + '3' + const maxHops = Math.max(1, parseInt(maxHopsRaw, 10) || 3) + + const defaultIntermediates = + network === 'mainnet' ? MAINNET_DEFAULT_INTERMEDIATES : TESTNET_DEFAULT_INTERMEDIATES + const rawIntermediates = + process.env[`ROUTING_INTERMEDIATES_${suffix}`] || + (network === 'testnet' ? process.env.ROUTING_INTERMEDIATES : undefined) || + defaultIntermediates + const intermediates = parseIntermediateAssets(rawIntermediates) + // ── Facilitator ─────────────────────────────────────────────────────────── const facilitatorSecretKey = process.env[`FACILITATOR_SECRET_KEY_${suffix}`] || @@ -245,6 +277,7 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { }, oracle: { enabled: oracleEnabled, reflectorContractId }, pairs: parseWatchedPairs(rawPairs), + routing: { maxHops, intermediates }, facilitator: { secretKey: facilitatorSecretKey, feeStroops: facilitatorFeeStroops }, } } @@ -357,6 +390,7 @@ export const config = { get aquarius() { return resolveNetwork(activeNetwork).aquarius }, get oracle() { return resolveNetwork(activeNetwork).oracle }, get pairs() { return resolveNetwork(activeNetwork).pairs }, + get routing() { return resolveNetwork(activeNetwork).routing }, get facilitator() { return resolveNetwork(activeNetwork).facilitator }, // ── Global / network-agnostic ────────────────────────────────────────────── diff --git a/src/types.ts b/src/types.ts index 98284a96..a56769ba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,13 +36,54 @@ export interface AggregatedPrice { stale: boolean } +export interface RouteHop { + assetIn: AssetId + assetOut: AssetId + venue: 'SDEX' | 'AMM' + poolId?: string + inputAmount: number + outputAmount: number + price: number + spotPrice: number + slippagePct: number +} + +export interface RouteOption { + hops: RouteHop[] + hopCount: number + estimatedOutput: number + effectivePrice: number + slippagePct: number +} + +export interface RouteProvenance { + sampledAt: string + network: string + venuesQueried: string[] + sampleCount: number + freshnessSeconds: number | null +} + export interface RouteInfo { - route: 'SDEX' | 'AMM' | 'SPLIT' | 'UNKNOWN' + route: 'SDEX' | 'AMM' | 'SPLIT' | 'MULTI_HOP' | 'UNKNOWN' sdexPrice: number ammPrice: number estimatedOutput: number slippagePct: number recommendation: string + network?: string + hops?: RouteHop[] + comparison?: { + direct: { + route: 'SDEX' | 'AMM' | 'SPLIT' | 'UNKNOWN' + sdexPrice: number + ammPrice: number + estimatedOutput: number + slippagePct: number + } + multiHop: RouteOption | null + } + provenance?: RouteProvenance } export interface WatchedPair {