From d3419232f8be17f5d4306e5c64e9796851b8efe9 Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Fri, 16 Oct 2026 19:53:11 +0100 Subject: [PATCH] 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, + } + } + ) +}