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 95fefb4d..ed86bf64 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,9 @@ REFLECTOR_CONTRACT_ID_TESTNET= # Comma-separated pairs to watch on testnet. # Format: "CODE:ISSUER/CODE:ISSUER". Use "native" for XLM. WATCHED_PAIRS_TESTNET=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 +# 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 @@ -99,6 +102,9 @@ AQUARIUS_ENABLED_MAINNET=true REFLECTOR_CONTRACT_ID_MAINNET= # Comma-separated pairs to watch on mainnet. WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN +# 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) --- @@ -115,6 +121,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 aab5d6b7..3a101ee7 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,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 | `/snapshot?network=testnet\|mainnet` | Whole-network snapshot: venues, TVL, 24h volume, top pairs, peg readings and data freshness in one cached, timestamped object | @@ -59,7 +60,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`, `/book/:assetA/:assetB`, `/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 c9f1adbe..1c4a503a 100644 --- a/openapi.json +++ b/openapi.json @@ -1539,6 +1539,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" } @@ -1676,6 +1677,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", @@ -3624,7 +3658,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)." } } }, @@ -3638,6 +3677,14 @@ ], "additionalProperties": false, "properties": { + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ], + "description": "The network answered for." + }, "data": { "type": "array", "items": { @@ -3720,6 +3767,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 96a83621..8bf28533 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1020,6 +1020,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 @@ -1103,6 +1108,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 @@ -2330,12 +2356,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: @@ -2386,6 +2425,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/__tests__/screener.test.ts b/src/__tests__/screener.test.ts index 6046f52f..7cfc7c64 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() }) @@ -230,6 +232,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' }) @@ -239,6 +250,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/app.ts b/src/app.ts index dfe3cef3..74328784 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,6 +22,7 @@ import { registerVolumeRoutes } from './routes/volumes' import { registerBookRoutes } from './routes/book' 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' @@ -164,6 +165,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 ──────────────────────────────────────────────── /** @@ -217,6 +260,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}`] || @@ -245,6 +302,7 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { }, oracle: { enabled: oracleEnabled, reflectorContractId }, pairs: parseWatchedPairs(rawPairs), + pegged: parsePeggedAssets(rawPegged), facilitator: { secretKey: facilitatorSecretKey, feeStroops: facilitatorFeeStroops }, } } @@ -357,6 +415,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, + } + } + ) +} diff --git a/src/routes/screener.ts b/src/routes/screener.ts index be7e5aff..7f4aa48c 100644 --- a/src/routes/screener.ts +++ b/src/routes/screener.ts @@ -232,7 +232,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,