From d3419232f8be17f5d4306e5c64e9796851b8efe9 Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Fri, 16 Oct 2026 19:53:11 +0100 Subject: [PATCH 1/2] feat: add GET /pegs route for comparative peg deviation monitoring --- .changeset/pegs-deviation-route.md | 7 + .env.example | 7 + README.md | 3 +- openapi.json | 140 +++++++++++++++ openapi.yaml | 98 +++++++++++ src/__tests__/pegs.test.ts | 272 +++++++++++++++++++++++++++++ src/api/schemas.ts | 56 ++++++ src/app.ts | 2 + src/config.ts | 59 +++++++ src/routes/pegs.ts | 220 +++++++++++++++++++++++ 10 files changed, 863 insertions(+), 1 deletion(-) create mode 100644 .changeset/pegs-deviation-route.md create mode 100644 src/__tests__/pegs.test.ts create mode 100644 src/routes/pegs.ts diff --git a/.changeset/pegs-deviation-route.md b/.changeset/pegs-deviation-route.md new file mode 100644 index 00000000..ce0a32e1 --- /dev/null +++ b/.changeset/pegs-deviation-route.md @@ -0,0 +1,7 @@ +--- +'lens': minor +--- + +Add `GET /pegs` endpoint reporting peg deviations and provenance across all configured pegged assets. +Targets and target prices are configured explicitly per network (omitting unconfigured assets). +Honours and echoes `?network=testnet|mainnet` and includes data provenance (venues, sample count, and freshness). diff --git a/.env.example b/.env.example index d37f04a6..72bb87b5 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,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 +# Comma-separated pegged assets with explicit target and peg price on testnet. +# Format: "ASSET[:ISSUER]=TARGET:TARGET_PRICE[:WINDOW_HOURS]" +PEGGED_ASSETS_TESTNET=USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5=USD:1.0 # --- Stellar Network — Mainnet --- HORIZON_URL_MAINNET=https://horizon.stellar.org @@ -77,6 +80,9 @@ AQUARIUS_ENABLED_MAINNET=true REFLECTOR_CONTRACT_ID_MAINNET= # Comma-separated pairs to watch on mainnet. WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN +# Comma-separated pegged assets with explicit target and peg price on mainnet. +# Format: "ASSET[:ISSUER]=TARGET:TARGET_PRICE[:WINDOW_HOURS]" +PEGGED_ASSETS_MAINNET=USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN=USD:1.0 # --- Venue endpoint overrides (optional, shared across networks unless a # paired _TESTNET / _MAINNET variant is set) --- @@ -93,6 +99,7 @@ RPC_URL=https://soroban-testnet.stellar.org NETWORK_PASSPHRASE=Test SDF Network ; September 2015 SOROSWAP_FACTORY_ADDRESS=CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY WATCHED_PAIRS=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 +PEGGED_ASSETS=USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5=USD:1.0 # Reflector oracle contract ID on testnet (falls back from paired REFLECTOR_CONTRACT_ID_TESTNET) REFLECTOR_CONTRACT_ID= diff --git a/README.md b/README.md index 2201d343..9eec1165 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity | GET | `/compare/:asset` | Lens vs configured oracle prices, with per-oracle deviation and availability | | GET | `/screener?sortBy=volume&order=desc&limit=20` | Screen pairs by volume, 24h change, liquidity and price | | GET | `/benchmark/:asset?target=USD` | Peg-deviation statistics versus a target | +| GET | `/pegs` | All configured pegged assets with current and worst deviation and provenance | | GET | `/basket?asset=XLM&asset=USDC&weight=0.6&weight=0.4` | Weighted price of a basket of assets | | GET | `/status` | Indexer health | | GET | `/discovery/resources?type=&payTo=&network=&extensions=&limit=&offset=` | Bazaar catalog of x402-discoverable resources (spec: [`bazaar`](https://github.com/x402-foundation/x402/blob/main/specs/extensions/bazaar.md)) | @@ -55,7 +56,7 @@ ones listed below still read a single instance-wide network. Per-request today: `/price/:assetA/:assetB` (its VWAP, OHLCV, AMM and best-route reads), `/price/:assetA/:assetB/route`, `/price/:assetA/:assetB/depth`, `/prices/history`, `/screener`, `/pools`, `/pairs`, `/spreads/:asset`, -`/volumes/:asset`, `/status`, and the x402 payment `network`/`payTo`. That list +`/volumes/:asset`, `/pegs`, `/status`, and the x402 payment `network`/`payTo`. That list is the same one `openapi.yaml` declares a `network` parameter on — if they ever disagree, the spec is the thing to fix. diff --git a/openapi.json b/openapi.json index b0576e01..e1b7e717 100644 --- a/openapi.json +++ b/openapi.json @@ -1272,6 +1272,39 @@ } } }, + "/pegs": { + "get": { + "operationId": "getPegs", + "summary": "Get peg deviation and window statistics for all configured pegged assets", + "security": [], + "parameters": [ + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "List of configured pegged assets and their peg deviations", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PegsResponse" + } + } + } + }, + "400": { + "description": "Invalid network parameter" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/basket": { "get": { "operationId": "getBasket", @@ -2579,6 +2612,113 @@ } } }, + "PegReport": { + "type": "object", + "required": [ + "asset", + "code", + "issuer", + "target", + "targetPrice", + "pairKey", + "currentPrice", + "currentDeviationBps", + "worstDeviationBps", + "window", + "provenance" + ], + "additionalProperties": false, + "properties": { + "asset": { + "type": "string" + }, + "code": { + "type": "string" + }, + "issuer": { + "type": "string", + "nullable": true + }, + "target": { + "type": "string" + }, + "targetPrice": { + "type": "number" + }, + "pairKey": { + "type": "string", + "nullable": true + }, + "currentPrice": { + "type": "number", + "nullable": true + }, + "currentDeviationBps": { + "type": "number", + "nullable": true + }, + "worstDeviationBps": { + "type": "number", + "nullable": true + }, + "window": { + "type": "string" + }, + "provenance": { + "type": "object", + "required": [ + "venues", + "sampleCount", + "freshnessSeconds", + "lastSampleTimestamp" + ], + "additionalProperties": false, + "properties": { + "venues": { + "type": "array", + "items": { + "type": "string" + } + }, + "sampleCount": { + "type": "integer" + }, + "freshnessSeconds": { + "type": "integer", + "nullable": true + }, + "lastSampleTimestamp": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + }, + "PegsResponse": { + "type": "object", + "required": [ + "network", + "pegs" + ], + "additionalProperties": false, + "properties": { + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, + "pegs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PegReport" + } + } + } + }, "BasketResponse": { "type": "object", "required": [ diff --git a/openapi.yaml b/openapi.yaml index 182db46d..e9b99e6f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -806,6 +806,27 @@ paths: '500': description: Benchmark computation failed + /pegs: + get: + operationId: getPegs + summary: Get peg deviation and window statistics for all configured pegged assets + security: [] + parameters: + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: List of configured pegged assets and their peg deviations + content: + application/json: + schema: + $ref: '#/components/schemas/PegsResponse' + '400': + description: Invalid network parameter + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + description: Internal server error + /basket: get: operationId: getBasket @@ -1611,6 +1632,83 @@ components: sampleCount: type: integer + PegReport: + type: object + required: + - asset + - code + - issuer + - target + - targetPrice + - pairKey + - currentPrice + - currentDeviationBps + - worstDeviationBps + - window + - provenance + additionalProperties: false + properties: + asset: + type: string + code: + type: string + issuer: + type: string + nullable: true + target: + type: string + targetPrice: + type: number + pairKey: + type: string + nullable: true + currentPrice: + type: number + nullable: true + currentDeviationBps: + type: number + nullable: true + worstDeviationBps: + type: number + nullable: true + window: + type: string + provenance: + type: object + required: + - venues + - sampleCount + - freshnessSeconds + - lastSampleTimestamp + additionalProperties: false + properties: + venues: + type: array + items: + type: string + sampleCount: + type: integer + freshnessSeconds: + type: integer + nullable: true + lastSampleTimestamp: + type: string + format: date-time + nullable: true + + PegsResponse: + type: object + required: [network, pegs] + additionalProperties: false + properties: + network: + type: string + enum: [testnet, mainnet] + pegs: + type: array + items: + $ref: '#/components/schemas/PegReport' + BasketResponse: type: object required: [basketPrice, components, weightSum, computedAt] diff --git a/src/__tests__/pegs.test.ts b/src/__tests__/pegs.test.ts new file mode 100644 index 00000000..18d43210 --- /dev/null +++ b/src/__tests__/pegs.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' + +const { mockQuery, mockGetNetworkConfig } = vi.hoisted(() => ({ + mockQuery: vi.fn(), + mockGetNetworkConfig: vi.fn(), +})) + +vi.mock('../db', () => ({ + pgPool: { query: mockQuery }, +})) + +vi.mock('../config', async () => { + const actual = await vi.importActual('../config') + return { + ...actual, + getNetworkConfig: mockGetNetworkConfig, + } +}) + +import { registerPegsRoutes } from '../routes/pegs' + +async function buildApp() { + const app = Fastify({ logger: false }) + await registerPegsRoutes(app) + await app.ready() + return app +} + +describe('GET /pegs', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('honours and echoes ?network= parameter', async () => { + mockGetNetworkConfig.mockReturnValue({ + pairs: [], + pegged: [], + }) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/pegs?network=mainnet', + }) + + expect(res.statusCode).toBe(200) + const data = res.json() + expect(data.network).toBe('mainnet') + expect(data.pegs).toEqual([]) + expect(mockGetNetworkConfig).toHaveBeenCalledWith('mainnet') + }) + + it('rejects an invalid network parameter with 400', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/pegs?network=ropsten', + }) + + expect(res.statusCode).toBe(400) + expect(res.json().error).toContain('network must be one of') + }) + + it('returns all configured pegged assets with current, worst deviation, and provenance', async () => { + const circleIssuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + mockGetNetworkConfig.mockReturnValue({ + pairs: [ + { + pairKey: `USDC:${circleIssuer}/USD`, + assetA: { code: 'USDC', issuer: circleIssuer }, + assetB: { code: 'USD', issuer: null }, + }, + ], + pegged: [ + { + asset: { code: 'USDC', issuer: circleIssuer }, + target: 'USD', + targetPrice: 1.0, + windowHours: 24, + }, + ], + }) + + mockQuery.mockResolvedValue({ + rows: [ + { + latest_price: '0.9998', + latest_ts: '2026-10-16T12:00:00.000Z', + latest_source: 'SDEX', + worst_deviation_bps: '5.2', + sample_count: 142, + venues: ['SDEX', 'AMM'], + window_last_ts: '2026-10-16T12:00:00.000Z', + }, + ], + }) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/pegs?network=mainnet', + }) + + expect(res.statusCode).toBe(200) + const data = res.json() + expect(data.network).toBe('mainnet') + expect(data.pegs).toHaveLength(1) + + const peg = data.pegs[0] + expect(peg.asset).toBe(`USDC:${circleIssuer}`) + expect(peg.code).toBe('USDC') + expect(peg.issuer).toBe(circleIssuer) + expect(peg.target).toBe('USD') + expect(peg.targetPrice).toBe(1.0) + expect(peg.pairKey).toBe(`USDC:${circleIssuer}/USD`) + expect(peg.currentPrice).toBe(0.9998) + // (0.9998 - 1.0) * 10000 = -2.0 bps + expect(peg.currentDeviationBps).toBeCloseTo(-2.0, 1) + expect(peg.worstDeviationBps).toBe(5.2) + expect(peg.window).toBe('24h') + expect(peg.provenance.venues).toEqual(['SDEX', 'AMM']) + expect(peg.provenance.sampleCount).toBe(142) + expect(typeof peg.provenance.freshnessSeconds).toBe('number') + expect(peg.provenance.lastSampleTimestamp).toBe('2026-10-16T12:00:00.000Z') + }) + + it('omits unconfigured assets completely', async () => { + const circleIssuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + mockGetNetworkConfig.mockReturnValue({ + // Pairs include XLM, BTC, ETH, and USDC, but only USDC is configured as pegged + pairs: [ + { + pairKey: 'BTC/USD', + assetA: { code: 'BTC', issuer: null }, + assetB: { code: 'USD', issuer: null }, + }, + { + pairKey: `USDC:${circleIssuer}/USD`, + assetA: { code: 'USDC', issuer: circleIssuer }, + assetB: { code: 'USD', issuer: null }, + }, + ], + pegged: [ + { + asset: { code: 'USDC', issuer: circleIssuer }, + target: 'USD', + targetPrice: 1.0, + windowHours: 24, + }, + ], + }) + + mockQuery.mockResolvedValue({ + rows: [ + { + latest_price: '1.0000', + latest_ts: '2026-10-16T12:00:00.000Z', + latest_source: 'SDEX', + worst_deviation_bps: '0', + sample_count: 10, + venues: ['SDEX'], + window_last_ts: '2026-10-16T12:00:00.000Z', + }, + ], + }) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/pegs?network=mainnet', + }) + + expect(res.statusCode).toBe(200) + const data = res.json() + // BTC must be completely absent, not present with 0 deviation + expect(data.pegs.find((p: any) => p.code === 'BTC')).toBeUndefined() + expect(data.pegs).toHaveLength(1) + expect(data.pegs[0].code).toBe('USDC') + }) + + it('reports null deviation and empty provenance for pegged assets with no trades or liquidity', async () => { + const euroIssuer = 'GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2' + mockGetNetworkConfig.mockReturnValue({ + pairs: [ + { + pairKey: `EURC:${euroIssuer}/EUR`, + assetA: { code: 'EURC', issuer: euroIssuer }, + assetB: { code: 'EUR', issuer: null }, + }, + ], + pegged: [ + { + asset: { code: 'EURC', issuer: euroIssuer }, + target: 'EUR', + targetPrice: 1.0, + windowHours: 24, + }, + ], + }) + + mockQuery.mockResolvedValue({ + rows: [ + { + latest_price: null, + latest_ts: null, + latest_source: null, + worst_deviation_bps: null, + sample_count: 0, + venues: null, + window_last_ts: null, + }, + ], + }) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/pegs?network=mainnet', + }) + + expect(res.statusCode).toBe(200) + const data = res.json() + expect(data.pegs).toHaveLength(1) + + const peg = data.pegs[0] + expect(peg.asset).toBe(`EURC:${euroIssuer}`) + expect(peg.currentPrice).toBeNull() + expect(peg.currentDeviationBps).toBeNull() + expect(peg.worstDeviationBps).toBeNull() + expect(peg.provenance).toEqual({ + venues: [], + sampleCount: 0, + freshnessSeconds: null, + lastSampleTimestamp: null, + }) + }) + + it('reports null when a configured pegged asset is not quoted/watched in pairs', async () => { + const rwaIssuer = 'GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2' + mockGetNetworkConfig.mockReturnValue({ + pairs: [], // no watched pairs + pegged: [ + { + asset: { code: 'TBILL', issuer: rwaIssuer }, + target: 'USD', + targetPrice: 100.0, + windowHours: 24, + }, + ], + }) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/pegs?network=mainnet', + }) + + expect(res.statusCode).toBe(200) + const data = res.json() + expect(data.pegs).toHaveLength(1) + + const peg = data.pegs[0] + expect(peg.asset).toBe(`TBILL:${rwaIssuer}`) + expect(peg.targetPrice).toBe(100.0) + expect(peg.pairKey).toBeNull() + expect(peg.currentPrice).toBeNull() + expect(peg.currentDeviationBps).toBeNull() + expect(peg.worstDeviationBps).toBeNull() + expect(peg.provenance.sampleCount).toBe(0) + }) +}) diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 90ecd10f..fafb2d77 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -254,6 +254,62 @@ export const benchmarkResponseSchema = { }, } as const +/** GET /pegs */ +export const pegsResponseSchema = { + type: 'object', + required: ['network', 'pegs'], + additionalProperties: false, + properties: { + network: { type: 'string', enum: ['testnet', 'mainnet'] }, + pegs: { + type: 'array', + items: { + type: 'object', + required: [ + 'asset', + 'code', + 'issuer', + 'target', + 'targetPrice', + 'pairKey', + 'currentPrice', + 'currentDeviationBps', + 'worstDeviationBps', + 'window', + 'provenance', + ], + additionalProperties: false, + properties: { + asset: { type: 'string' }, + code: { type: 'string' }, + issuer: { type: ['string', 'null'] }, + target: { type: 'string' }, + targetPrice: { type: 'number' }, + pairKey: { type: ['string', 'null'] }, + currentPrice: { type: ['number', 'null'] }, + currentDeviationBps: { type: ['number', 'null'] }, + worstDeviationBps: { type: ['number', 'null'] }, + window: { type: 'string' }, + provenance: { + type: 'object', + required: ['venues', 'sampleCount', 'freshnessSeconds', 'lastSampleTimestamp'], + additionalProperties: false, + properties: { + venues: { + type: 'array', + items: { type: 'string' }, + }, + sampleCount: { type: 'integer' }, + freshnessSeconds: { type: ['integer', 'null'] }, + lastSampleTimestamp: { type: ['string', 'null'] }, + }, + }, + }, + }, + }, + }, +} as const + /** * Install response-shape validation on a Fastify instance. * diff --git a/src/app.ts b/src/app.ts index da11e200..624662a5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -21,6 +21,7 @@ import { registerPriceRoutes } from './routes/price' import { registerVolumeRoutes } from './routes/volumes' import { registerSpreadsRoutes } from './routes/spreads' import { registerBenchmarkRoutes } from './routes/benchmark' +import { registerPegsRoutes } from './routes/pegs' import { registerOracleRoutes } from './routes/oracle' import { registerBasketRoutes } from './routes/basket' import { registerDiscoveryRoutes } from './routes/discovery' @@ -133,6 +134,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { + // format: ASSET:ISSUER=TARGET:TARGET_PRICE[:WINDOW_HOURS] + // e.g. "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN=USD:1.0" + const [assetStr, targetDef] = entry.trim().split('=') + if (!assetStr || !targetDef) { + throw new Error( + `Invalid pegged asset format: "${entry}". Expected "ASSET[:ISSUER]=TARGET:TARGET_PRICE[:WINDOW_HOURS]"` + ) + } + const asset = parseAsset(assetStr) + const targetParts = targetDef.split(':') + const target = targetParts[0].trim().toUpperCase() + const targetPrice = parseFloat(targetParts[1] ?? '1.0') + if (isNaN(targetPrice)) { + throw new Error(`Invalid target price in pegged asset: "${entry}"`) + } + const windowHours = targetParts[2] ? parseInt(targetParts[2], 10) : undefined + return { + asset, + target, + targetPrice, + windowHours: windowHours && !isNaN(windowHours) ? windowHours : 24, + } + }) +} + // ── Per-network config builder ──────────────────────────────────────────────── /** @@ -198,6 +241,20 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { (network === 'testnet' ? process.env.WATCHED_PAIRS : undefined) || (network === 'mainnet' ? MAINNET_DEFAULT_PAIRS : '') + // ── Pegged assets ───────────────────────────────────────────────────────── + // Configured assets that are expected to track a peg target. + // Default on mainnet includes USDC (Circle) pegged to USD at 1.0. + // Default on testnet includes testnet USDC pegged to USD at 1.0. + const TESTNET_DEFAULT_PEGGED = + 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5=USD:1.0' + const MAINNET_DEFAULT_PEGGED = + 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN=USD:1.0' + + const rawPegged = + process.env[`PEGGED_ASSETS_${suffix}`] || + (network === 'testnet' ? process.env.PEGGED_ASSETS : undefined) || + (network === 'mainnet' ? MAINNET_DEFAULT_PEGGED : TESTNET_DEFAULT_PEGGED) + // ── Facilitator ─────────────────────────────────────────────────────────── const facilitatorSecretKey = process.env[`FACILITATOR_SECRET_KEY_${suffix}`] || @@ -226,6 +283,7 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { }, oracle: { enabled: oracleEnabled, reflectorContractId }, pairs: parseWatchedPairs(rawPairs), + pegged: parsePeggedAssets(rawPegged), facilitator: { secretKey: facilitatorSecretKey, feeStroops: facilitatorFeeStroops }, } } @@ -322,6 +380,7 @@ export const config = { get aquarius() { return resolveNetwork(activeNetwork).aquarius }, get oracle() { return resolveNetwork(activeNetwork).oracle }, get pairs() { return resolveNetwork(activeNetwork).pairs }, + get pegged() { return resolveNetwork(activeNetwork).pegged }, get facilitator() { return resolveNetwork(activeNetwork).facilitator }, // ── Global / network-agnostic ────────────────────────────────────────────── diff --git a/src/routes/pegs.ts b/src/routes/pegs.ts new file mode 100644 index 00000000..5e52c694 --- /dev/null +++ b/src/routes/pegs.ts @@ -0,0 +1,220 @@ +import type { FastifyInstance } from 'fastify' +import { pgPool } from '../db' +import { getNetworkConfig, activeNetwork, type NetworkName, type PeggedAssetConfig } from '../config' +import { pegsResponseSchema } from '../api/schemas' +import type { WatchedPair } from '../types' +import '../middleware/network' + +export interface PegProvenance { + venues: string[] + sampleCount: number + freshnessSeconds: number | null + lastSampleTimestamp: string | null +} + +export interface PegReport { + asset: string + code: string + issuer: string | null + target: string + targetPrice: number + pairKey: string | null + currentPrice: number | null + currentDeviationBps: number | null + worstDeviationBps: number | null + window: string + provenance: PegProvenance +} + +/** + * Match a pegged asset and target against configured watched pairs. + */ +function findWatchedPair( + pegged: PeggedAssetConfig, + pairs: readonly WatchedPair[] +): { pair: WatchedPair; isAssetA: boolean } | null { + const normAssetCode = pegged.asset.code.toUpperCase() + const normTargetCode = pegged.target.toUpperCase() + + for (const pair of pairs) { + const pACode = pair.assetA.code.toUpperCase() + const pBCode = pair.assetB.code.toUpperCase() + + // Case 1: pair is (pegged.asset, target) + if (pACode === normAssetCode && pBCode === normTargetCode) { + if (!pegged.asset.issuer || !pair.assetA.issuer || pegged.asset.issuer === pair.assetA.issuer) { + return { pair, isAssetA: true } + } + } + + // Case 2: pair is (target, pegged.asset) + if (pBCode === normAssetCode && pACode === normTargetCode) { + if (!pegged.asset.issuer || !pair.assetB.issuer || pegged.asset.issuer === pair.assetB.issuer) { + return { pair, isAssetA: false } + } + } + } + + return null +} + +/** + * Register GET /pegs route. + */ +export async function registerPegsRoutes(app: FastifyInstance) { + app.get<{ + Querystring: { network?: string } + }>( + '/pegs', + { schema: { response: { 200: pegsResponseSchema } } }, + async (req, reply) => { + const requested = req.query.network + if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { + return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + } + const network: NetworkName = req.network ?? (requested as NetworkName) ?? activeNetwork + + const networkConfig = getNetworkConfig(network) + const peggedConfigs = networkConfig.pegged ?? [] + const watchedPairs = networkConfig.pairs ?? [] + + const reports: PegReport[] = [] + + for (const pegged of peggedConfigs) { + const fullAsset = pegged.asset.issuer ? `${pegged.asset.code}:${pegged.asset.issuer}` : pegged.asset.code + const match = findWatchedPair(pegged, watchedPairs) + + if (!match) { + // Say when you cannot answer: watched pair not found / unquoted + reports.push({ + asset: fullAsset, + code: pegged.asset.code, + issuer: pegged.asset.issuer, + target: pegged.target, + targetPrice: pegged.targetPrice, + pairKey: null, + currentPrice: null, + currentDeviationBps: null, + worstDeviationBps: null, + window: `${pegged.windowHours ?? 24}h`, + provenance: { + venues: [], + sampleCount: 0, + freshnessSeconds: null, + lastSampleTimestamp: null, + }, + }) + continue + } + + const { pair, isAssetA } = match + const priceExpr = isAssetA ? 'price::numeric' : '1.0 / NULLIF(price::numeric, 0)' + const targetPrice = pegged.targetPrice > 0 ? pegged.targetPrice : 1.0 + const windowHours = pegged.windowHours ?? 24 + + // Compute latest price, window worst deviation, and provenance (venues, sampleCount, freshness) + // Query both latest observation (without window restriction) and window stats + const query = ` + WITH latest_obs AS ( + SELECT + ${priceExpr} AS latest_price, + timestamp AS latest_ts, + source AS latest_source + FROM price_points + WHERE pair_key = $1 AND network = $2 + ORDER BY timestamp DESC + LIMIT 1 + ), + window_stats AS ( + SELECT + MAX(ABS(${priceExpr} - $3::numeric)) / $3::numeric * 10000 AS worst_deviation_bps, + COUNT(*)::int AS sample_count, + ARRAY_AGG(DISTINCT source) FILTER (WHERE source IS NOT NULL) AS venues, + MAX(timestamp) AS window_last_ts + FROM price_points + WHERE pair_key = $1 + AND network = $2 + AND timestamp > NOW() - ($4 || ' hours')::interval + ) + SELECT + l.latest_price, + l.latest_ts, + l.latest_source, + w.worst_deviation_bps, + w.sample_count, + w.venues, + w.window_last_ts + FROM latest_obs l + FULL OUTER JOIN window_stats w ON TRUE + ` + + try { + const result = await pgPool.query(query, [pair.pairKey, network, targetPrice, windowHours]) + const row = result.rows[0] + + const latestPriceRaw = row?.latest_price + const latestPrice = latestPriceRaw !== null && latestPriceRaw !== undefined ? parseFloat(latestPriceRaw) : null + const currentDeviationBps = + latestPrice !== null ? ((latestPrice - targetPrice) / targetPrice) * 10000 : null + + const worstDevRaw = row?.worst_deviation_bps + const worstDeviationBps = worstDevRaw !== null && worstDevRaw !== undefined ? parseFloat(worstDevRaw) : null + + const sampleCount = Number(row?.sample_count) || 0 + const venues: string[] = Array.isArray(row?.venues) + ? row.venues + : row?.latest_source + ? [row.latest_source] + : [] + + const lastTs: Date | null = row?.latest_ts ? new Date(row.latest_ts) : null + const freshnessSeconds = lastTs ? Math.max(0, Math.floor((Date.now() - lastTs.getTime()) / 1000)) : null + + reports.push({ + asset: fullAsset, + code: pegged.asset.code, + issuer: pegged.asset.issuer, + target: pegged.target, + targetPrice, + pairKey: pair.pairKey, + currentPrice: latestPrice, + currentDeviationBps, + worstDeviationBps, + window: `${windowHours}h`, + provenance: { + venues, + sampleCount, + freshnessSeconds, + lastSampleTimestamp: lastTs ? lastTs.toISOString() : null, + }, + }) + } catch (err) { + // Say when computation cannot be performed for this asset + reports.push({ + asset: fullAsset, + code: pegged.asset.code, + issuer: pegged.asset.issuer, + target: pegged.target, + targetPrice, + pairKey: pair.pairKey, + currentPrice: null, + currentDeviationBps: null, + worstDeviationBps: null, + window: `${windowHours}h`, + provenance: { + venues: [], + sampleCount: 0, + freshnessSeconds: null, + lastSampleTimestamp: null, + }, + }) + } + } + + return { + network, + pegs: reports, + } + } + ) +} From b4c3bea4afb302f53210dd4040d8bbe18fa8c3e7 Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Fri, 16 Oct 2026 19:59:28 +0100 Subject: [PATCH 2/2] feat(screener): define liquidity in spec and response, name unit, and echo network --- openapi.json | 16 +++++++++++- openapi.yaml | 18 +++++++++++++ src/__tests__/screener.test.ts | 46 +++++++++++++++++++++++++++++++++- src/routes/screener.ts | 18 ++++++++++++- 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/openapi.json b/openapi.json index e1b7e717..24cb8e82 100644 --- a/openapi.json +++ b/openapi.json @@ -1145,6 +1145,7 @@ "name": "liquidity", "in": "query", "required": false, + "description": "Filter for pairs with total AMM pool liquidity greater than or equal to this value. Liquidity is defined as the sum across all active AMM pools for the pair of (reserve_a * spot_price + reserve_b), valued in the quote asset (asset_b) at the latest snapshot spot price.", "schema": { "type": "number" } @@ -2516,7 +2517,12 @@ "type": "number" }, "liquidity": { - "type": "number" + "type": "number", + "description": "Total liquidity across all active AMM pools for this pair on the network, computed as sum(reserve_a * spot_price + reserve_b), valued in the quote asset (asset_b) at each pool's latest snapshot spot price." + }, + "liquidity_unit": { + "type": "string", + "description": "The asset symbol/identifier that liquidity is denominated in, corresponding to the counter/quote asset (asset_b) of the pair (the asset after the slash in pair_key)." } } }, @@ -2530,6 +2536,14 @@ ], "additionalProperties": false, "properties": { + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ], + "description": "The network answered for." + }, "data": { "type": "array", "items": { diff --git a/openapi.yaml b/openapi.yaml index e9b99e6f..e134b4ec 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -729,6 +729,11 @@ paths: - name: liquidity in: query required: false + description: >- + Filter for pairs with total AMM pool liquidity greater than or equal to this value. + Liquidity is defined as the sum across all active AMM pools for the pair of + (reserve_a * spot_price + reserve_b), valued in the quote asset (asset_b) at the + latest snapshot spot price. schema: type: number - name: sortBy @@ -1576,12 +1581,25 @@ components: type: number liquidity: type: number + description: >- + Total liquidity across all active AMM pools for this pair on the network, + computed as sum(reserve_a * spot_price + reserve_b), valued in the quote + asset (asset_b) at each pool's latest snapshot spot price. + liquidity_unit: + type: string + description: >- + The asset symbol/identifier that liquidity is denominated in, corresponding + to the counter/quote asset (asset_b) of the pair (the asset after the slash in pair_key). ScreenerResponse: type: object required: [data, nextCursor, hasMore, total] additionalProperties: false properties: + network: + type: string + enum: [testnet, mainnet] + description: The network answered for. data: type: array items: diff --git a/src/__tests__/screener.test.ts b/src/__tests__/screener.test.ts index b27b5878..3e5b8b85 100644 --- a/src/__tests__/screener.test.ts +++ b/src/__tests__/screener.test.ts @@ -41,16 +41,18 @@ describe('GET /screener', () => { mockQuery.mockResolvedValue({ rows: [] }) }) - it('returns rows without a market_cap field', async () => { + it('returns rows without a market_cap field, with liquidity_unit and network', async () => { mockQuery.mockResolvedValue({ rows: [makeRow()] }) const app = await buildApp() const res = await app.inject({ method: 'GET', url: '/screener' }) expect(res.statusCode).toBe(200) const body = res.json() + expect(body.network).toBe('testnet') expect(body.data).toHaveLength(1) expect(body.data[0]).not.toHaveProperty('market_cap') expect(body.data[0].liquidity).toBe(50000) + expect(body.data[0].liquidity_unit).toBe('USDC') expect(lastCall().sql).not.toMatch(/market_cap/) await app.close() }) @@ -189,6 +191,15 @@ describe('GET /screener', () => { await app.close() }) + it('echoes the requested network in the response', async () => { + mockQuery.mockResolvedValue({ rows: [makeRow()] }) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/screener?network=mainnet' }) + expect(res.statusCode).toBe(200) + expect(res.json().network).toBe('mainnet') + await app.close() + }) + it('rejects an unknown network before querying', async () => { const app = await buildApp() const res = await app.inject({ method: 'GET', url: '/screener?network=devnet' }) @@ -198,6 +209,39 @@ describe('GET /screener', () => { }) }) + describe('liquidity definition', () => { + it('defines liquidity in the SQL query as reserve_a * spot_price + reserve_b summed over active AMM pool snapshots', async () => { + const app = await buildApp() + await app.inject({ method: 'GET', url: '/screener' }) + + const { sql } = lastCall() + // Verifies SQL computes pool_liq exactly as reserve_a * spot_price + reserve_b + expect(sql).toMatch(/\(ps\.reserve_a\s*\*\s*ps\.spot_price\s*\+\s*ps\.reserve_b\)\s+AS\s+pool_liq/) + // Verifies sources are AMM pools + expect(sql).toMatch(/FROM price_points\s+WHERE network = \$1 AND source = 'AMM' AND pool_id IS NOT NULL/) + // Verifies summed across pools per pair + expect(sql).toMatch(/SELECT pp\.pair_key,\s*SUM\(ls\.pool_liq\)\s+AS\s+liquidity/) + await app.close() + }) + + it('names the unit as the quote asset in pair_key', async () => { + mockQuery.mockResolvedValue({ + rows: [ + makeRow({ pair_key: 'XLM/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' }), + makeRow({ pair_key: 'BTC/EURC' }), + ], + }) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/screener' }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.data[0].liquidity_unit).toBe('USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN') + expect(body.data[1].liquidity_unit).toBe('EURC') + await app.close() + }) + }) + it('returns 500 without leaking the error when the query fails', async () => { mockQuery.mockRejectedValue(new Error('connection refused postgres://user:secret@host')) const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/src/routes/screener.ts b/src/routes/screener.ts index 783059d4..f3fa56af 100644 --- a/src/routes/screener.ts +++ b/src/routes/screener.ts @@ -9,6 +9,18 @@ import '../middleware/network' // declares req.network on the FastifyRequest typ const MARKET_CAP_UNSUPPORTED = 'market_cap is not supported: Lens has no circulating supply data to compute it from. Use liquidity instead' +/** + * Liquidity Definition for /screener: + * - Sources/Venues: Stellar AMM liquidity pools (source = 'AMM' in price_points, + * mapped to pool_snapshots for the pair). + * - Valuation & Formula: Summed across all active AMM pools for the pair of + * `(reserve_a * spot_price + reserve_b)`. Since spot_price is quoted in + * `asset_b` per unit of `asset_a` (i.e. reserve_b / reserve_a), `reserve_a * spot_price` + * evaluates to the value of reserve_a denominated in asset_b. + * - Unit: Denominated in the quote asset (counter asset, `asset_b`, the second asset + * in `pair_key` "BASE/QUOTE"). Named explicitly as `liquidity_unit` in each row. + * - Prices: Spot price from the latest snapshot of each liquidity pool on the requested network. + */ export async function registerScreenerRoutes(app: FastifyInstance) { app.get<{ Querystring: { @@ -183,7 +195,11 @@ export async function registerScreenerRoutes(app: FastifyInstance) { } return { - data, + network: req.network ?? activeNetwork, + data: data.map(row => ({ + ...row, + liquidity_unit: row.pair_key.split('/')[1] ?? 'quote', + })), nextCursor, hasMore, total: null,