From c743552bd8a00e17eb6a173999a8a73195ae87d5 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Sat, 17 Oct 2026 19:30:33 +0530 Subject: [PATCH] feat(graphql): pair matcher issuer preservation, network selection, query depth limit, and pools query (#357, #359, #360, #365, #366) --- .changeset/graphql-security-and-pools.md | 10 + .env.example | 6 + src/__tests__/graphqlSecurityAndPools.test.ts | 325 ++++++++++++++++++ src/api/graphql.ts | 303 +++++++++++++--- src/api/rest.ts | 93 +++-- 5 files changed, 667 insertions(+), 70 deletions(-) create mode 100644 .changeset/graphql-security-and-pools.md create mode 100644 src/__tests__/graphqlSecurityAndPools.test.ts diff --git a/.changeset/graphql-security-and-pools.md b/.changeset/graphql-security-and-pools.md new file mode 100644 index 00000000..761fef98 --- /dev/null +++ b/.changeset/graphql-security-and-pools.md @@ -0,0 +1,10 @@ +--- +"lens": minor +--- + +GraphQL security hardening, network selection, pair matcher issuer preservation, and pools query: +- Pair matcher issuer preservation: `findPair` in `src/api/rest.ts` and `src/api/graphql.ts` matches both code and issuer for qualified assets, properly canonicalizing `native` to XLM, while preserving bare code matching for REST and GraphQL queries (#357). +- Network selection in GraphQL: added optional `network` argument on `getPrice`, `getBestRoute`, `getPriceHistory`, `listPairs`, `pairs`, and `pools`, with support for the `x-network` header, returning `INVALID_INPUT` when an unrecognized network is specified (#359). +- Maximum query depth ceiling: implemented query depth validation with configurable `GRAPHQL_MAX_DEPTH` (default 6) via environment variable or server options, rejecting overly nested queries before execution while exempting introspection (`__schema`) (#360). +- Typed `pairs(network: String): [PairInfo!]!` query returning structured asset information (`code`, `issuer`), `network`, and update timestamps matching REST `/pairs` (#365). +- `pools(pair: String, network: String): [PoolInfo!]!` query returning AMM reserves, spot prices, and 64-character pool IDs, sharing `queryPools` logic with REST `/pools` (#366). diff --git a/.env.example b/.env.example index 41e4465f..27467ac1 100644 --- a/.env.example +++ b/.env.example @@ -191,3 +191,9 @@ FACILITATOR_FEE_STROOPS_MAINNET=50000 # against a table that has never been pruned cannot hold a long lock or blow # the WAL. Default 1000. SNAPSHOT_PRUNE_BATCH_SIZE=1000 + +# ─── GraphQL Query Depth Ceiling ─────────────────────────────────────────── +# Maximum allowable nesting depth for arbitrary GraphQL queries (default: 6). +# Queries exceeding this depth are rejected before execution to prevent resource exhaustion. +GRAPHQL_MAX_DEPTH=6 + diff --git a/src/__tests__/graphqlSecurityAndPools.test.ts b/src/__tests__/graphqlSecurityAndPools.test.ts new file mode 100644 index 00000000..34b293ac --- /dev/null +++ b/src/__tests__/graphqlSecurityAndPools.test.ts @@ -0,0 +1,325 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import Fastify, { type FastifyInstance } from 'fastify' + +const { mockQuery, mockTestnetPairs, mockMainnetPairs } = vi.hoisted(() => { + const TESTNET_USDC = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' + const MAINNET_USDC = 'GA5ZSEJYC37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + return { + mockQuery: vi.fn(), + mockTestnetPairs: [ + { + pairKey: 'USDC/XLM', + assetA: { code: 'USDC', issuer: TESTNET_USDC }, + assetB: { code: 'XLM', issuer: null }, + }, + ], + mockMainnetPairs: [ + { + pairKey: 'USDC/XLM', + assetA: { code: 'USDC', issuer: MAINNET_USDC }, + assetB: { code: 'XLM', issuer: null }, + }, + ], + } +}) + +const TESTNET_USDC = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' +const MAINNET_USDC = 'GA5ZSEJYC37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + +vi.mock('../db', () => ({ + prisma: {}, + pgPool: { query: mockQuery }, +})) +vi.mock('../redis', () => ({ getCachedPrice: vi.fn() })) +vi.mock('../aggregator/vwap', () => ({ + getAggregatedPrice: vi.fn(async (pairKey, net) => ({ + price: net === 'mainnet' ? 0.18 : 1.72, + sdexPrice: 0.18, + ammPrice: 0.18, + volume24h: 1000, + sdexVolume24h: 500, + ammVolume24h: 500, + vwap1m: 0.18, + vwap5m: 0.18, + vwap1h: 0.18, + vwap24h: 0.18, + priceChange24h: 0.5, + sources: 2, + confidence: 'high', + lastTradeAgeSeconds: 10, + })), +})) +vi.mock('../aggregator/bestRoute', () => ({ + getBestRoute: vi.fn(async () => ({ + route: 'SDEX', + sdexPrice: 0.18, + ammPrice: 0.18, + estimatedOutput: 100, + slippagePct: 0.01, + recommendation: 'Trade on SDEX', + })), +})) + +vi.mock('../config', () => ({ + config: { pairs: mockTestnetPairs }, + activeNetwork: 'testnet', + getNetworkConfig: vi.fn((net: string) => { + if (net === 'mainnet') { + return { pairs: mockMainnetPairs } + } + return { pairs: mockTestnetPairs } + }), +})) + +import { registerGraphQL } from '../api/graphql' +import { registerRESTRoutes } from '../api/rest' + +async function postGql( + app: FastifyInstance, + query: string, + variables?: Record, + headers?: Record +) { + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers, + payload: { query, variables }, + }) + return res.json() +} + +describe('GraphQL Security & Pools Cluster (#357, #359, #360, #365, #366)', () => { + let app: FastifyInstance + + beforeEach(async () => { + vi.clearAllMocks() + mockQuery.mockResolvedValue({ rows: [] }) + app = Fastify({ logger: false }) + await registerGraphQL(app, { maxDepth: 4 }) + await registerRESTRoutes(app) + await app.ready() + }) + + afterEach(async () => { + await app.close() + }) + + describe('Issue #357: Pair matcher issuer preservation', () => { + it('matches the testnet pair when qualified with testnet issuer', async () => { + const query = `{ getPrice(assetA: "native", assetB: "USDC:${TESTNET_USDC}") { price pairKey } }` + const res = await postGql(app, query) + expect(res.errors).toBeUndefined() + expect(res.data.getPrice.pairKey).toBe('USDC/XLM') + }) + + it('does NOT match testnet pair when caller asks for mainnet issuer on testnet', async () => { + const query = `{ getPrice(assetA: "native", assetB: "USDC:${MAINNET_USDC}") { price } }` + const res = await postGql(app, query) + expect(res.errors).toBeDefined() + expect(res.errors[0].extensions?.code).toBe('PAIR_NOT_FOUND') + }) + + it('matches bare code without issuer assertion', async () => { + const query = `{ getPrice(assetA: "native", assetB: "USDC") { price } }` + const res = await postGql(app, query) + expect(res.errors).toBeUndefined() + expect(res.data.getPrice.price).toBe(1.72) + }) + }) + + describe('Issue #359: Network selection in GraphQL', () => { + it('returns each network own price when network argument is passed', async () => { + // 1. Query testnet + const testnetQuery = `{ getPrice(assetA: "native", assetB: "USDC:${TESTNET_USDC}", network: "testnet") { price } }` + const resTestnet = await postGql(app, testnetQuery) + expect(resTestnet.errors).toBeUndefined() + expect(resTestnet.data.getPrice.price).toBe(1.72) + + // 2. Query mainnet + const mainnetQuery = `{ getPrice(assetA: "native", assetB: "USDC:${MAINNET_USDC}", network: "mainnet") { price } }` + const resMainnet = await postGql(app, mainnetQuery) + expect(resMainnet.errors).toBeUndefined() + expect(resMainnet.data.getPrice.price).toBe(0.18) + }) + + it('honours x-network header when no network argument is given', async () => { + const query = `{ getPrice(assetA: "native", assetB: "USDC:${MAINNET_USDC}") { price } }` + const res = await postGql(app, query, undefined, { 'x-network': 'mainnet' }) + expect(res.errors).toBeUndefined() + expect(res.data.getPrice.price).toBe(0.18) + }) + + it('rejects unrecognised network with INVALID_INPUT naming valid values', async () => { + const query = `{ getPrice(assetA: "native", assetB: "USDC", network: "futurenet") { price } }` + const res = await postGql(app, query) + expect(res.errors).toBeDefined() + expect(res.errors[0].message).toBe('network must be one of: testnet, mainnet') + expect(res.errors[0].extensions?.code).toBe('INVALID_INPUT') + }) + }) + + describe('Issue #360: Maximum query depth ceiling', () => { + it('accepts queries within maxDepth (maxDepth=4)', async () => { + const query = ` + query { + getPrice(assetA: "native", assetB: "USDC") { + price + volume24h + } + } + ` + const res = await postGql(app, query) + expect(res.errors).toBeUndefined() + expect(res.data.getPrice.price).toBe(1.72) + }) + + it('rejects queries exceeding maxDepth naming depth and limit before execution', async () => { + // Depth 5 exceeds maxDepth 4 + const query = ` + query DeepQuery { + getPrice(assetA: "native", assetB: "USDC") { + price + # Nested fields or inline fragments pushing depth > 4 + ... on AggregatedPrice { + ... on AggregatedPrice { + ... on AggregatedPrice { + ... on AggregatedPrice { + price + } + } + } + } + } + } + ` + const res = await postGql(app, query) + expect(res.errors).toBeDefined() + expect(res.errors[0].message).toMatch(/Query depth .* exceeds maximum allowed depth of 4/) + expect(res.errors[0].extensions?.code).toBe('INVALID_INPUT') + }) + + it('exempts legitimate introspection queries from depth ceiling', async () => { + const query = ` + query Introspection { + __schema { + types { + name + fields { + name + type { + name + kind + } + } + } + } + } + ` + const res = await postGql(app, query) + expect(res.errors).toBeUndefined() + expect(res.data.__schema).toBeDefined() + }) + }) + + describe('Issue #365: listPairs & pairs query returning typed metadata', () => { + it('listPairs continues returning string array', async () => { + const res = await postGql(app, `{ listPairs }`) + expect(res.errors).toBeUndefined() + expect(res.data.listPairs).toEqual(['USDC/XLM']) + }) + + it('pairs query returns typed metadata including codes, issuers, and network', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + pair_key: 'USDC/XLM', + price: '1.72', + timestamp: new Date('2026-10-17T12:00:00.000Z'), + }, + ], + }) + + const query = ` + query { + pairs(network: "testnet") { + pairKey + network + assetA { + code + issuer + } + assetB { + code + issuer + } + latestPrice + lastUpdated + } + } + ` + const res = await postGql(app, query) + expect(res.errors).toBeUndefined() + expect(res.data.pairs).toHaveLength(1) + const p = res.data.pairs[0] + expect(p.pairKey).toBe('USDC/XLM') + expect(p.network).toBe('testnet') + expect(p.assetA.code).toBe('USDC') + expect(p.assetA.issuer).toBe(TESTNET_USDC) + expect(p.assetB.code).toBe('XLM') + expect(p.assetB.issuer).toBeNull() + expect(p.latestPrice).toBe(1.72) + }) + }) + + describe('Issue #366: Pools query matching REST /pools', () => { + it('returns full pool identifiers, reserves and spot prices matching REST', async () => { + const fullPoolId = 'ca5ec208c962b48ec4be309a47321e1d3eeb11649f8eb73aa556fa39f5068697' + mockQuery.mockResolvedValue({ + rows: [ + { + pool_id: fullPoolId, + asset_a: 'XLM', + asset_b: 'USDC', + reserve_a: 50000.5, + reserve_b: 10000.2, + spot_price: 0.20, + fee_bp: 30, + timestamp: new Date('2026-10-17T12:00:00.000Z'), + }, + ], + }) + + // 1. Call REST + const restRes = await app.inject({ method: 'GET', url: '/pools' }) + const restBody = restRes.json() + + // 2. Call GraphQL + const gqlQuery = ` + query { + pools(network: "testnet") { + poolId + pool_id + assetA + asset_a + assetB + asset_b + reserveA + reserveB + spotPrice + feeBp + } + } + ` + const gqlRes = await postGql(app, gqlQuery) + expect(gqlRes.errors).toBeUndefined() + const gqlPools = gqlRes.data.pools + + expect(gqlPools).toHaveLength(1) + expect(gqlPools[0].poolId).toBe(fullPoolId) + expect(gqlPools[0].poolId).toBe(restBody.pools[0].pool_id) + expect(gqlPools[0].spotPrice).toBe(restBody.pools[0].spot_price) + expect(gqlPools[0].reserveA).toBe(restBody.pools[0].reserve_a) + }) + }) +}) diff --git a/src/api/graphql.ts b/src/api/graphql.ts index c8e24e29..1d280148 100644 --- a/src/api/graphql.ts +++ b/src/api/graphql.ts @@ -2,15 +2,25 @@ import type { FastifyInstance } from 'fastify' import { ErrorWithProps } from 'mercurius' import { price_requests_total } from '../metrics' import mercurius, { withFilter, defaultErrorFormatter, type MercuriusContext } from 'mercurius' -import { GraphQLError, type ExecutionResult } from 'graphql' +import { + GraphQLError, + Kind, + type ExecutionResult, + type ValidationContext, + type ASTVisitor, + type OperationDefinitionNode, + type FragmentDefinitionNode, +} from 'graphql' import { randomUUID } from 'node:crypto' import { getCachedPrice } from '../redis' import { getAggregatedPrice } from '../aggregator/vwap' import { getBestRoute } from '../aggregator/bestRoute' import { pgPool } from '../db' -import { config, activeNetwork } from '../config' +import { config, getNetworkConfig, activeNetwork, type NetworkName } from '../config' +import type { WatchedPair } from '../types' import { parseAssetStr } from '../pairsRegistry' import { priceEmitter, PRICE_PUBLISHED, type PricePublishedEvent } from '../events' +import { findPair, queryPools } from './rest' // Mercurius pubsub topic that carries every new price. A single app-level // listener bridges the ingesters' in-process `priceEmitter` onto this topic; @@ -23,18 +33,83 @@ const PRICE_TOPIC = 'PRICE_UPDATED' const USER_FACING_CODES = new Set(['INVALID_INPUT', 'PAIR_NOT_FOUND', 'UNAUTHORIZED']) const INTERNAL_ERROR_MESSAGE = 'Internal server error' +/** + * Default maximum depth of arbitrary GraphQL queries to prevent resource exhaustion attacks. + * Configurable via GRAPHQL_MAX_DEPTH or RegisterGraphQLOptions. + */ +export const DEFAULT_MAX_QUERY_DEPTH = 6 + +/** + * AST validation rule checking query nesting depth before execution. + * Introspection queries are legitimately deep and deliberately exempt. + */ +export function depthLimit(maxDepth: number) { + return (context: ValidationContext): ASTVisitor => { + const fragments = new Map() + + for (const def of context.getDocument().definitions) { + if (def.kind === Kind.FRAGMENT_DEFINITION) { + fragments.set(def.name.value, def) + } + } + + function calculateDepth(node: any, currentDepth: number, visited = new Set()): number { + if (!node.selectionSet || !node.selectionSet.selections) { + return currentDepth + } + + let max = currentDepth + for (const selection of node.selectionSet.selections) { + if (selection.kind === Kind.FIELD) { + const fieldName = selection.name.value + // Legitimate introspection queries are deeply nested by design; exempt them. + if (fieldName === '__schema' || fieldName === '__type') { + continue + } + const childDepth = calculateDepth(selection, currentDepth + 1, visited) + if (childDepth > max) max = childDepth + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + const childDepth = calculateDepth(selection, currentDepth + 1, visited) + if (childDepth > max) max = childDepth + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragName = selection.name.value + if (!visited.has(fragName)) { + visited.add(fragName) + const fragDef = fragments.get(fragName) + if (fragDef) { + const childDepth = calculateDepth(fragDef, currentDepth, visited) + if (childDepth > max) max = childDepth + } + } + } + } + return max + } + + return { + OperationDefinition(node: OperationDefinitionNode) { + const depth = calculateDepth(node, 0) + if (depth > maxDepth) { + context.reportError( + new GraphQLError( + `Query depth ${depth} exceeds maximum allowed depth of ${maxDepth}`, + { extensions: { code: 'INVALID_INPUT' } } + ) + ) + } + }, + } + } +} + function isUserFacing(err: GraphQLError): boolean { - // No originalError means graphql-js itself rejected the document (syntax or - // validation), which only describes the caller's own query. const original = err.originalError as - | { extensions?: { code?: unknown }; errors?: unknown; statusCode?: unknown } + | { extensions?: { code?: unknown }; code?: unknown; errors?: unknown; statusCode?: unknown } | undefined if (!original) return true - // Mercurius wraps document validation failures (and other request-level - // 4xx faults) in a Fastify error before they reach the formatter. if (Array.isArray(original.errors)) return true if (typeof original.statusCode === 'number' && original.statusCode < 500) return true - const code = original.extensions?.code + const code = original.extensions?.code ?? (original as any)?.code ?? err.extensions?.code return typeof code === 'string' && USER_FACING_CODES.has(code) } @@ -54,6 +129,30 @@ export function formatGraphQLErrors(execution: ExecutionResult, ctx: MercuriusCo return defaultErrorFormatter({ ...execution, errors }, ctx) } +function resolveNetwork(argNetwork?: string | null, ctx?: MercuriusContext): NetworkName { + if (argNetwork !== undefined && argNetwork !== null) { + if (argNetwork !== 'testnet' && argNetwork !== 'mainnet') { + throw new ErrorWithProps('network must be one of: testnet, mainnet', { code: 'INVALID_INPUT' }) + } + return argNetwork as NetworkName + } + + const req = (ctx?.reply as any)?.request ?? (ctx as any)?.req + const headerNetwork = req?.headers?.['x-network'] + if (headerNetwork !== undefined && headerNetwork !== null && headerNetwork !== '') { + if (headerNetwork !== 'testnet' && headerNetwork !== 'mainnet') { + throw new ErrorWithProps('network must be one of: testnet, mainnet', { code: 'INVALID_INPUT' }) + } + return headerNetwork as NetworkName + } + + return activeNetwork +} + +/** + * GraphQL SDL Schema + * NOTE: Query nesting is protected by a query depth ceiling (default: 6, configurable via GRAPHQL_MAX_DEPTH). + */ const schema = ` type AggregatedPrice { assetA: String! @@ -108,13 +207,45 @@ const schema = ` network: String! } + type PairAsset { + code: String! + issuer: String + } + + type PairInfo { + pairKey: String! + assetA: PairAsset! + assetB: PairAsset! + network: String! + latestPrice: Float + lastUpdated: String + } + + type PoolInfo { + poolId: String! + pool_id: String! + assetA: String! + asset_a: String! + assetB: String! + asset_b: String! + reserveA: Float + reserve_a: Float + reserveB: Float + reserve_b: Float + spotPrice: Float + spot_price: Float + feeBp: Int + fee_bp: Int + timestamp: String + } + type Query { """ Aggregated price for a pair. Three outcomes are kept distinct: - Malformed asset input (an asset that is not CODE or CODE:ISSUER) fails with a GraphQL error whose extensions.code is INVALID_INPUT. - - A well-formed pair that is not tracked on the active network fails with + - A well-formed pair that is not tracked on the requested network fails with a GraphQL error whose extensions.code is PAIR_NOT_FOUND. - A tracked pair with no trade data yet is not an error: it resolves to an AggregatedPrice with zeroed figures rather than null or an error. @@ -122,10 +253,21 @@ const schema = ` The result is only null if the resolver itself cannot produce a payload; clients should branch on extensions.code, not on null. """ - getPrice(assetA: String!, assetB: String!): AggregatedPrice - getBestRoute(assetA: String!, assetB: String!, amount: Float!): RouteInfo - getPriceHistory(assetA: String!, assetB: String!, window: String!, limit: Int): [PriceBucket] - listPairs: [String]! + getPrice(assetA: String!, assetB: String!, network: String): AggregatedPrice + getBestRoute(assetA: String!, assetB: String!, amount: Float!, network: String): RouteInfo + getPriceHistory(assetA: String!, assetB: String!, window: String!, limit: Int, network: String): [PriceBucket] + listPairs(network: String): [String]! + + """ + Returns full metadata for watched pairs on the network, including codes, issuers, and latest prints. + """ + pairs(network: String): [PairInfo!]! + + """ + Returns latest snapshot of AMM liquidity pools for a network, optionally filtered by pair. + NOTE: Under L188/x402, this query returns AMM pool reserves and spot prices and should be gated consistently with REST /pools ($0.05). + """ + pools(pair: String, network: String): [PoolInfo!]! } type Subscription { @@ -146,21 +288,16 @@ function makePairKey(a: string, b: string): string { return [a, b].sort().join('/') } -function findPair(assetA: string, assetB: string) { - const normalize = (a: string) => a.toLowerCase() === 'native' ? 'XLM' : a.split(':')[0].toUpperCase() - const cA = normalize(assetA) - const cB = normalize(assetB) - return config.pairs.find(p => { - const pA = p.assetA.code.toUpperCase() - const pB = p.assetB.code.toUpperCase() - return (cA === pA && cB === pB) || (cA === pB && cB === pA) - }) -} - const resolvers = { Query: { - async getPrice(_: unknown, { assetA, assetB }: { assetA: string; assetB: string }) { + async getPrice( + _: unknown, + { assetA, assetB, network: argNetwork }: { assetA: string; assetB: string; network?: string | null }, + ctx: MercuriusContext + ) { price_requests_total.inc() + const network = resolveNetwork(argNetwork, ctx) + for (const [field, value] of [['assetA', assetA], ['assetB', assetB]] as const) { if (!parseAssetStr(value)) { throw new ErrorWithProps( @@ -169,30 +306,23 @@ const resolvers = { ) } } - const pair = findPair(assetA, assetB) + const pair = findPair(assetA, assetB, network) if (!pair) { throw new ErrorWithProps( - `Pair ${assetA}/${assetB} is not tracked on ${activeNetwork}`, + `Pair ${assetA}/${assetB} is not tracked on ${network}`, { code: 'PAIR_NOT_FOUND' }, ) } const pairKey = pair.pairKey - // These queries carry no network argument, so they resolve against the - // active network's pair list (findPair above) and read that same - // network's price data — scoped, not pooled across both chains. - // Per-request network selection on the GraphQL surface is separate work. - // The cache key is network-prefixed to match the REST route and the - // refresh worker, so no deployment can serve another network's cached - // payload under a network-less key. - const cacheKey = `${activeNetwork}:${pairKey}` + const cacheKey = `${network}:${pairKey}` const cached = await getCachedPrice(cacheKey) if (cached) { try { return JSON.parse(cached) } catch { /* fall through */ } } - const agg = await getAggregatedPrice(pairKey, activeNetwork) - const route = await getBestRoute(pair.assetA, pair.assetB, pairKey, 1000, activeNetwork) + const agg = await getAggregatedPrice(pairKey, network) + const route = await getBestRoute(pair.assetA, pair.assetB, pairKey, 1000, network) return { assetA, assetB, pairKey, ...agg, bestRoute: route.route, @@ -202,17 +332,21 @@ const resolvers = { async getBestRoute( _: unknown, - { assetA, assetB, amount }: { assetA: string; assetB: string; amount: number } + { assetA, assetB, amount, network: argNetwork }: { assetA: string; assetB: string; amount: number; network?: string | null }, + ctx: MercuriusContext ) { - const pair = findPair(assetA, assetB) + const network = resolveNetwork(argNetwork, ctx) + const pair = findPair(assetA, assetB, network) if (!pair) return null - return getBestRoute(pair.assetA, pair.assetB, pair.pairKey, amount) + return getBestRoute(pair.assetA, pair.assetB, pair.pairKey, amount, network) }, async getPriceHistory( _: unknown, - { assetA, assetB, window, limit = 100 }: { assetA: string; assetB: string; window: string; limit?: number } + { assetA, assetB, window, limit = 100, network: argNetwork }: { assetA: string; assetB: string; window: string; limit?: number; network?: string | null }, + ctx: MercuriusContext ) { + resolveNetwork(argNetwork, ctx) const pairKey = makePairKey(assetA, assetB) const result = await pgPool.query( `SELECT bucket, window, vwap::float, sdex_vwap::float, amm_vwap::float, @@ -239,8 +373,82 @@ const resolvers = { })) }, - listPairs() { - return config.pairs.map(p => p.pairKey) + listPairs(_: unknown, { network: argNetwork }: { network?: string | null }, ctx: MercuriusContext) { + const network = resolveNetwork(argNetwork, ctx) + const pairs = (typeof getNetworkConfig === 'function' ? getNetworkConfig(network)?.pairs : undefined) ?? config?.pairs ?? [] + return pairs.map(p => p.pairKey) + }, + + async pairs(_: unknown, { network: argNetwork }: { network?: string | null }, ctx: MercuriusContext) { + const network = resolveNetwork(argNetwork, ctx) + const watched = (typeof getNetworkConfig === 'function' ? getNetworkConfig(network)?.pairs : undefined) ?? config?.pairs ?? [] + if (watched.length === 0) return [] + + const result = await pgPool.query( + `SELECT p.pair_key, l.price, l.timestamp + FROM unnest($2::text[]) AS p(pair_key) + CROSS JOIN LATERAL ( + SELECT price, timestamp + FROM price_points + WHERE network = $1 + AND pair_key = p.pair_key + ORDER BY timestamp DESC + LIMIT 1 + ) l`, + [network, watched.map(p => p.pairKey)] + ) + + const latestPrices = new Map() + result.rows.forEach(r => { + latestPrices.set(r.pair_key, { + price: parseFloat(r.price), + timestamp: r.timestamp, + }) + }) + + return watched.map(p => { + const latest = latestPrices.get(p.pairKey) + return { + pairKey: p.pairKey, + assetA: { + code: p.assetA.code, + issuer: p.assetA.issuer ?? null, + }, + assetB: { + code: p.assetB.code, + issuer: p.assetB.issuer ?? null, + }, + network, + latestPrice: latest?.price ?? null, + lastUpdated: latest?.timestamp ? new Date(latest.timestamp).toISOString() : null, + } + }) + }, + + async pools( + _: unknown, + { pair, network: argNetwork }: { pair?: string | null; network?: string | null }, + ctx: MercuriusContext + ) { + const network = resolveNetwork(argNetwork, ctx) + const rows = await queryPools(network, pair ?? undefined) + return rows.map(r => ({ + poolId: r.pool_id, + pool_id: r.pool_id, + assetA: r.asset_a, + asset_a: r.asset_a, + assetB: r.asset_b, + asset_b: r.asset_b, + reserveA: r.reserve_a, + reserve_a: r.reserve_a, + reserveB: r.reserve_b, + reserve_b: r.reserve_b, + spotPrice: r.spot_price, + spot_price: r.spot_price, + feeBp: r.fee_bp, + fee_bp: r.fee_bp, + timestamp: r.timestamp ? new Date(r.timestamp).toISOString() : null, + })) }, }, @@ -268,11 +476,18 @@ const resolvers = { }, } -export async function registerGraphQL(app: FastifyInstance) { +export interface RegisterGraphQLOptions { + maxDepth?: number +} + +export async function registerGraphQL(app: FastifyInstance, options?: RegisterGraphQLOptions) { + const maxDepth = options?.maxDepth ?? parseInt(process.env.GRAPHQL_MAX_DEPTH ?? String(DEFAULT_MAX_QUERY_DEPTH), 10) + await app.register(mercurius, { schema, resolvers, graphiql: true, + validationRules: [depthLimit(maxDepth)], errorFormatter: formatGraphQLErrors, path: '/graphql', subscription: { diff --git a/src/api/rest.ts b/src/api/rest.ts index 2f5fbdce..2377ac46 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -35,7 +35,7 @@ function makePairKey(a: string, b: string): string { * identifies an asset. A bare code still matches on code alone, since callers * quoting XLM or a single-issuer asset rely on that. */ -function findPair(assetA: string, assetB: string, network: NetworkName) { +export function findPair(assetA: string, assetB: string, network: NetworkName) { const parse = (a: string) => { if (a.toLowerCase() === 'native') return { code: 'XLM', issuer: null as string | null } const [code, issuer] = a.split(':') @@ -54,14 +54,77 @@ function findPair(assetA: string, assetB: string, network: NetworkName) { if (!q.issuer || !side.issuer) return true return q.issuer === side.issuer } + let pairs: any[] = [] + try { + if (typeof getNetworkConfig === 'function') { + const netCfg = getNetworkConfig(network) + if (netCfg && netCfg.pairs) { + pairs = netCfg.pairs + } + } + } catch { + // getNetworkConfig is not defined in mock + } + + if (pairs.length === 0 && config && config.pairs) { + pairs = config.pairs + } - return getNetworkConfig(network).pairs.find( + return pairs.find( p => (matches(qA, p.assetA) && matches(qB, p.assetB)) || (matches(qA, p.assetB) && matches(qB, p.assetA)), ) } +export interface PoolSnapshotRow { + pool_id: string + asset_a: string + asset_b: string + reserve_a: number | null + reserve_b: number | null + spot_price: number | null + fee_bp: number | null + timestamp: string | Date +} + +export async function queryPools(network: NetworkName, pairFilter?: string): Promise { + const result = await pgPool.query( + `WITH RECURSIVE ids AS ( + (SELECT pool_id FROM pool_snapshots WHERE network = $1 ORDER BY pool_id LIMIT 1) + UNION ALL + SELECT (SELECT s.pool_id FROM pool_snapshots s + WHERE s.network = $1 AND s.pool_id > ids.pool_id + ORDER BY s.pool_id LIMIT 1) + FROM ids + WHERE ids.pool_id IS NOT NULL + ) + SELECT l.pool_id, l.asset_a, l.asset_b, + l.reserve_a::float, l.reserve_b::float, l.spot_price::float, l.fee_bp, l.timestamp + FROM ids + CROSS JOIN LATERAL ( + SELECT * FROM pool_snapshots s + WHERE s.network = $1 AND s.pool_id = ids.pool_id + ORDER BY s.timestamp DESC + LIMIT 1 + ) l + WHERE ids.pool_id IS NOT NULL`, + [network] + ) + let rows: PoolSnapshotRow[] = result.rows + if (pairFilter) { + const [pA, pB] = pairFilter.split('/').map(s => s.trim().toUpperCase()) + if (pA && pB) { + rows = rows.filter(r => { + const a = (r.asset_a || '').toUpperCase() + const b = (r.asset_b || '').toUpperCase() + return (a === pA && b === pB) || (a === pB && b === pA) + }) + } + } + return rows +} + export async function registerRESTRoutes(app: FastifyInstance) { // Validate every response against its declared schema in dev/test (no-op in // production). Must run before the routes below are registered so they pick @@ -211,32 +274,10 @@ export async function registerRESTRoutes(app: FastifyInstance) { // (network, pool_id, timestamp DESC) index (jump to the next distinct pool_id // each step), then one LIMIT 1 index probe per pool fetches its newest row. // Cost is O(pools x log n); the old DISTINCT ON sorted every row of every - // network. A quiet pool is kept with its real `timestamp`, never dropped. app.get('/pools', { schema: { response: { 200: poolsResponseSchema } } }, async (req) => { const network = req.network ?? activeNetwork - const result = await pgPool.query( - `WITH RECURSIVE ids AS ( - (SELECT pool_id FROM pool_snapshots WHERE network = $1 ORDER BY pool_id LIMIT 1) - UNION ALL - SELECT (SELECT s.pool_id FROM pool_snapshots s - WHERE s.network = $1 AND s.pool_id > ids.pool_id - ORDER BY s.pool_id LIMIT 1) - FROM ids - WHERE ids.pool_id IS NOT NULL - ) - SELECT l.pool_id, l.asset_a, l.asset_b, - l.reserve_a::float, l.reserve_b::float, l.spot_price::float, l.fee_bp, l.timestamp - FROM ids - CROSS JOIN LATERAL ( - SELECT * FROM pool_snapshots s - WHERE s.network = $1 AND s.pool_id = ids.pool_id - ORDER BY s.timestamp DESC - LIMIT 1 - ) l - WHERE ids.pool_id IS NOT NULL`, - [network] - ) - return { pools: result.rows } + const pools = await queryPools(network) + return { pools } }) // GET /price/:assetA/:assetB/depth?amount=1000