From ebb21a3e35d672f46fdced2543e0f822d73e2106 Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Mon, 26 Oct 2026 15:29:33 +0100 Subject: [PATCH] feat: add GET /pegs route for comparative peg deviation monitoring --- .changeset/pegs-deviation-route.md | 8 + .env.example | 12 + README.md | 1 + clients/typescript/src/schema.d.ts | 79 +++++ openapi.json | 140 +++++++++ openapi.yaml | 98 ++++++ scripts/gen-snippets.ts | 2 +- scripts/generate-readme.ts | 4 + snippets/python/analytics.py | 4 + snippets/typescript/analytics.ts | 9 + src/__tests__/pegs.test.ts | 475 +++++++++++++++++++++++++++++ src/api/schemas.ts | 56 ++++ src/app.ts | 2 + src/config.ts | 78 +++++ src/routes/pegs.ts | 233 ++++++++++++++ src/routes/snapshot.ts | 30 +- 16 files changed, 1223 insertions(+), 8 deletions(-) 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..f2cee2f3 --- /dev/null +++ b/.changeset/pegs-deviation-route.md @@ -0,0 +1,8 @@ +--- +'lens': minor +--- + +Add `GET /pegs` (`/v1/pegs`) endpoint reporting current and rolling-window worst peg deviations (in basis points) and provenance (venues, sample count, and freshness) across all configured pegged assets (`PEGGED_ASSETS_TESTNET`, `PEGGED_ASSETS_MAINNET`, `PEGGED_ASSETS`). +- Defaults to Circle `USDC` pegged to `USD:1.0` per network (`USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5=USD:1.0` on testnet and `USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN=USD:1.0` on mainnet) when `PEGGED_ASSETS*` is unset so deployed instances report the canonical USD stablecoin peg out of the box rather than returning an empty list. +- Shares `DEFAULT_PEG_TOLERANCE_BPS = 100` (100 bps = 1.0%) with `GET /snapshot` (`PEG_TOLERANCE_BPS = 100`) and the peg-deviation alerting engine (#322, default band = 0.01 = 100 bps). +- `GET /snapshot` consults `NetworkConfig.pegged` first when evaluating watched-pair pegs so configured targets and non-1.0 target prices never disagree between `/pegs` and `/snapshot`, while retaining its built-in 1:1 fiat stablecoin ticker map (`PEGGED_ASSETS`) as a fallback for unconfigured watched pairs and stablecoin-sibling reference quotes (`USDC/USDT`). diff --git a/.env.example b/.env.example index 023d668d..eb9f6373 100644 --- a/.env.example +++ b/.env.example @@ -138,6 +138,10 @@ SEP40_ENABLED_TESTNET=false # 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, target price, and optional rolling lookback window (hours) on testnet. +# Format: "ASSET[:ISSUER]=TARGET:TARGET_PRICE[:WINDOW_HOURS]" (window defaults to 24h when omitted). +# Unconfigured assets are omitted from GET /pegs rather than reported at zero deviation. +PEGGED_ASSETS_TESTNET=USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5=USD:1.0 # Multi-hop routing configuration for testnet ROUTING_MAX_HOPS_TESTNET=3 ROUTING_INTERMEDIATES_TESTNET=XLM:native,USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 @@ -183,6 +187,11 @@ SEP40_PUBLISHER_SECRET_KEY_MAINNET= SEP40_ENABLED_MAINNET=false # Comma-separated pairs to watch on mainnet. WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN +# Comma-separated pegged assets with explicit target, target price, and optional rolling lookback window (hours) on mainnet. +# Format: "ASSET[:ISSUER]=TARGET:TARGET_PRICE[:WINDOW_HOURS]" (window defaults to 24h when omitted). +# Example with multiple pegs and custom window: +# USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN=USD:1.0,EURC:GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2=EUR:1.0:24 +PEGGED_ASSETS_MAINNET=USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN=USD:1.0 # Multi-hop routing configuration for mainnet ROUTING_MAX_HOPS_MAINNET=3 ROUTING_INTERMEDIATES_MAINNET=XLM:native,USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN @@ -211,6 +220,9 @@ DEFINDEX_FACTORY_ADDRESS=CDSCWE4GLNBYYTES2OCYDFQA2LLY4RBIAX6ZI32VSUXD7GO6HRPO4A3 # single-network testnet value cannot override mainnet's built-in factory. PHOENIX_FACTORY_ADDRESS= WATCHED_PAIRS=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 +# Shared fallback for pegged assets when PEGGED_ASSETS_ is unset. +# Format: "ASSET[:ISSUER]=TARGET:TARGET_PRICE[:WINDOW_HOURS]" +PEGGED_ASSETS=USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5=USD:1.0 # Routing configuration fallbacks ROUTING_MAX_HOPS=3 ROUTING_INTERMEDIATES=XLM:native,USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 diff --git a/README.md b/README.md index 3b71b97e..a141d00f 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ npx @modelcontextprotocol/inspector npm run mcp:server | GET | `/v1/screener` | Screen pairs by volume, change, liquidity and price | | GET | `/v1/movers` | Get biggest gainers, biggest losers, and volume movers | | GET | `/v1/benchmark/:asset` | Get peg-deviation statistics for an asset against a target | +| GET | `/v1/pegs` | Get peg deviation and window statistics for all configured pegged assets | | GET | `/v1/basket` | Compute the weighted price of a basket of assets | | GET | `/v1/correlation` | Compute price correlation matrix across a basket of assets over a window | | GET | `/v1/discovery/resources` | Browse the x402 Bazaar catalog of discoverable resources | diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 80975b4b..a04cc05d 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -659,6 +659,23 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/pegs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get peg deviation and window statistics for all configured pegged assets */ + get: operations["getPegs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/basket": { parameters: { query?: never; @@ -1907,6 +1924,30 @@ export interface components { onChainAgeSeconds?: number | null; cexAgeSeconds?: number | null; }; + 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: { + venues: string[]; + sampleCount: number; + freshnessSeconds: number | null; + /** Format: date-time */ + lastSampleTimestamp: string | null; + }; + }; + PegsResponse: { + /** @enum {string} */ + network: "testnet" | "mainnet"; + pegs: components["schemas"]["PegReport"][]; + }; }; responses: { /** @description Payment Required */ @@ -3591,6 +3632,44 @@ export interface operations { }; }; }; + getPegs: { + parameters: { + query?: { + /** @description Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested. */ + network?: components["parameters"]["NetworkQuery"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of configured pegged assets and their peg deviations */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PegsResponse"]; + }; + }; + /** @description Invalid network parameter */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getBasket: { parameters: { query: { diff --git a/openapi.json b/openapi.json index baf25e53..1c1de93b 100644 --- a/openapi.json +++ b/openapi.json @@ -2984,6 +2984,39 @@ } } }, + "/v1/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" + } + } + } + }, "/v1/basket": { "get": { "operationId": "getBasket", @@ -7684,6 +7717,113 @@ "nullable": true } } + }, + "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" + } + } + } } } } diff --git a/openapi.yaml b/openapi.yaml index e8d2d231..ec80d250 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2049,6 +2049,27 @@ paths: schema: $ref: '#/components/schemas/InternalErrorResponse' + /v1/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 + /v1/basket: get: operationId: getBasket @@ -4837,3 +4858,80 @@ components: cexAgeSeconds: type: integer nullable: true + + 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' diff --git a/scripts/gen-snippets.ts b/scripts/gen-snippets.ts index 6a2fdb58..e205d905 100644 --- a/scripts/gen-snippets.ts +++ b/scripts/gen-snippets.ts @@ -16,7 +16,7 @@ const GROUPS: { name: string; prefixes: string[] }[] = [ { name: 'analytics', prefixes: [ - '/volumes', '/spreads', '/compare', '/screener', '/benchmark', '/basket', + '/volumes', '/spreads', '/compare', '/screener', '/benchmark', '/pegs', '/basket', '/tvl', '/rates', '/dispersion', '/movers', '/correlation', '/coverage', '/premium', ], }, diff --git a/scripts/generate-readme.ts b/scripts/generate-readme.ts index f8960cf1..7666a420 100644 --- a/scripts/generate-readme.ts +++ b/scripts/generate-readme.ts @@ -118,6 +118,10 @@ export const ENDPOINT_METADATA: Record = {} + const res = await fetch(url, { headers }) + console.log("getPegs", res.status, await res.json()) +} + // getBasket: GET /v1/basket { const url = new URL("/v1/basket", BASE_URL) diff --git a/src/__tests__/pegs.test.ts b/src/__tests__/pegs.test.ts new file mode 100644 index 00000000..64db7b24 --- /dev/null +++ b/src/__tests__/pegs.test.ts @@ -0,0 +1,475 @@ +import { describe, it, expect, vi, beforeEach, afterEach } 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, PEG_TOLERANCE_BPS } from '../routes/pegs' +import { parsePeggedAssets, DEFAULT_PEG_TOLERANCE_BPS } from '../config' + +async function buildApp() { + const app = Fastify({ logger: false }) + await registerPegsRoutes(app) + await app.ready() + return app +} + +describe('parsePeggedAssets', () => { + it('returns an empty list for blank input', () => { + expect(parsePeggedAssets('')).toEqual([]) + expect(parsePeggedAssets(' ')).toEqual([]) + }) + + it('parses single and multiple pegged asset definitions with optional windowHours', () => { + const circleIssuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + const eurocIssuer = 'GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2' + const parsed = parsePeggedAssets( + `USDC:${circleIssuer}=USD:1.0, EURC:${eurocIssuer}=USD:1.08:12` + ) + + expect(parsed).toEqual([ + { + asset: { code: 'USDC', issuer: circleIssuer }, + target: 'USD', + targetPrice: 1.0, + windowHours: 24, + }, + { + asset: { code: 'EURC', issuer: eurocIssuer }, + target: 'USD', + targetPrice: 1.08, + windowHours: 12, + }, + ]) + }) + + it('requires an explicit targetPrice and names the offending entry on malformed input', () => { + const circleIssuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + expect(() => parsePeggedAssets(`USDC:${circleIssuer}`)).toThrow( + /Invalid pegged asset format: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"/ + ) + expect(() => parsePeggedAssets(`USDC:${circleIssuer}=USD`)).toThrow( + /Expected "ASSET\[:ISSUER\]=TARGET:TARGET_PRICE\[:WINDOW_HOURS\]"/ + ) + expect(() => parsePeggedAssets(`USDC:${circleIssuer}=USD:notanumber`)).toThrow( + /Invalid target price in pegged asset/ + ) + expect(() => parsePeggedAssets(`USDC:${circleIssuer}=USD:0`)).toThrow( + /Invalid target price in pegged asset/ + ) + }) + + it('shares the 100 bps (1%) default peg tolerance band with snapshot and peg alerts (#322)', () => { + expect(DEFAULT_PEG_TOLERANCE_BPS).toBe(100) + expect(PEG_TOLERANCE_BPS).toBe(100) + }) +}) + +describe('GET /pegs', () => { + const savedEnv: Record = {} + const envKeys = [ + 'PEGGED_ASSETS', + 'PEGGED_ASSETS_TESTNET', + 'PEGGED_ASSETS_MAINNET', + 'WATCHED_PAIRS', + 'WATCHED_PAIRS_TESTNET', + 'WATCHED_PAIRS_MAINNET', + ] + + beforeEach(() => { + vi.clearAllMocks() + for (const k of envKeys) { + savedEnv[k] = process.env[k] + } + }) + + afterEach(() => { + for (const k of envKeys) { + if (savedEnv[k] === undefined) delete process.env[k] + else process.env[k] = savedEnv[k] + } + }) + + 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) + }) + + it('defaults to Circle USDC=USD:1.0 on both networks when PEGGED_ASSETS* is unset in process.env', async () => { + delete process.env.PEGGED_ASSETS + delete process.env.PEGGED_ASSETS_TESTNET + delete process.env.PEGGED_ASSETS_MAINNET + + vi.resetModules() + const realConfig = await vi.importActual('../config') + mockGetNetworkConfig.mockImplementation((net: 'testnet' | 'mainnet') => + realConfig.getNetworkConfig(net) + ) + + expect(realConfig.getNetworkConfig('mainnet').pegged).toEqual([ + { + asset: { + code: 'USDC', + issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + target: 'USD', + targetPrice: 1.0, + windowHours: 24, + }, + ]) + expect(realConfig.getNetworkConfig('testnet').pegged).toEqual([ + { + asset: { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }, + target: 'USD', + targetPrice: 1.0, + windowHours: 24, + }, + ]) + + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/pegs?network=mainnet', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.network).toBe('mainnet') + expect(body.pegs).toHaveLength(1) + expect(body.pegs[0].asset).toBe( + 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + ) + expect(body.pegs[0].target).toBe('USD') + expect(body.pegs[0].targetPrice).toBe(1.0) + }) + + it('wires PEGGED_ASSETS_ and PEGGED_ASSETS through the real getNetworkConfig into GET /pegs', async () => { + const circleIssuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + const eurocIssuer = 'GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2' + + process.env.WATCHED_PAIRS_MAINNET = `USDC:${circleIssuer}/USD,EURC:${eurocIssuer}/USD` + process.env.PEGGED_ASSETS_MAINNET = `USDC:${circleIssuer}=USD:1.0,EURC:${eurocIssuer}=USD:1.08:12` + delete process.env.PEGGED_ASSETS_TESTNET + process.env.PEGGED_ASSETS = `USDC:${circleIssuer}=USD:1.0` + + vi.resetModules() + const realConfig = await vi.importActual('../config') + mockGetNetworkConfig.mockImplementation((net: 'testnet' | 'mainnet') => + realConfig.getNetworkConfig(net) + ) + + // Verify real getNetworkConfig populated `pegged` on both networks (paired var on mainnet, shared fallback on testnet) + expect(realConfig.getNetworkConfig('mainnet').pegged).toHaveLength(2) + expect(realConfig.getNetworkConfig('testnet').pegged).toHaveLength(1) + + mockQuery + .mockResolvedValueOnce({ + rows: [ + { + latest_price: '1.0002', + latest_ts: '2026-10-16T12:00:00.000Z', + latest_source: 'SDEX', + worst_deviation_bps: '4.0', + sample_count: 80, + venues: ['SDEX'], + window_last_ts: '2026-10-16T12:00:00.000Z', + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + latest_price: '1.0854', + latest_ts: '2026-10-16T12:00:00.000Z', + latest_source: 'AMM', + worst_deviation_bps: '65.0', + sample_count: 45, + venues: ['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 body = res.json() + expect(body.network).toBe('mainnet') + expect(body.pegs).toHaveLength(2) + + expect(body.pegs[0]).toMatchObject({ + asset: `USDC:${circleIssuer}`, + target: 'USD', + targetPrice: 1.0, + window: '24h', + currentPrice: 1.0002, + worstDeviationBps: 4.0, + }) + expect(body.pegs[0].currentDeviationBps).toBeCloseTo(2.0, 1) + + expect(body.pegs[1]).toMatchObject({ + asset: `EURC:${eurocIssuer}`, + target: 'USD', + targetPrice: 1.08, + window: '12h', + currentPrice: 1.0854, + worstDeviationBps: 65.0, + }) + // (1.0854 - 1.08) / 1.08 * 10000 = +50 bps + expect(body.pegs[1].currentDeviationBps).toBeCloseTo(50.0, 1) + }) +}) diff --git a/src/api/schemas.ts b/src/api/schemas.ts index d001707e..0e57c3df 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -984,6 +984,62 @@ export const venuesResponseSchema = { }, } 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 9fb6f2a8..85de28aa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,6 +29,7 @@ import { registerBookRoutes } from './routes/book' import { registerSpreadsRoutes } from './routes/spreads' import { registerRateRoutes } from './routes/rates' import { registerBenchmarkRoutes } from './routes/benchmark' +import { registerPegsRoutes } from './routes/pegs' import { registerOracleRoutes } from './routes/oracle' import { registerBasketRoutes } from './routes/basket' import { registerCorrelationRoutes } from './routes/correlation' @@ -250,6 +251,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise parseAsset(a.trim())) @@ -197,6 +224,42 @@ function parseWatchedPairs(raw: string): WatchedPair[] { }) } +// ── Pegged assets parsing ─────────────────────────────────────────────────── + +export function parsePeggedAssets(raw: string): PeggedAssetConfig[] { + if (!raw.trim()) return [] + return raw.split(',').map(entry => { + // 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 rawTargetPrice = targetParts[1]?.trim() + if (!target || !rawTargetPrice) { + throw new Error( + `Invalid pegged asset format: "${entry}". Expected "ASSET[:ISSUER]=TARGET:TARGET_PRICE[:WINDOW_HOURS]"` + ) + } + const targetPrice = Number(rawTargetPrice) + if (!Number.isFinite(targetPrice) || targetPrice <= 0) { + 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 > 0 ? windowHours : 24, + } + }) +} + // ── Per-network config builder ──────────────────────────────────────────────── /** @@ -444,6 +507,19 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { (network === 'testnet' ? process.env.WATCHED_PAIRS : undefined) || (network === 'mainnet' ? MAINNET_DEFAULT_PAIRS : '') + // ── Pegged assets ───────────────────────────────────────────────────────── + // Defaults to Circle USDC pegged to USD at 1.0 on each network (matching + // MAINNET_DEFAULT_PAIRS and TESTNET_DEFAULT_INTERMEDIATES) so an instance + // that leaves PEGGED_ASSETS_* unset still reports the canonical USD + // stablecoin peg out of the box rather than returning []. Setting + // PEGGED_ASSETS_="" explicitly still yields []. + const defaultPegged = + network === 'mainnet' ? MAINNET_DEFAULT_PEGGED : TESTNET_DEFAULT_PEGGED + const rawPegged = + process.env[`PEGGED_ASSETS_${suffix}`] ?? + process.env.PEGGED_ASSETS ?? + defaultPegged + // ── Multi-hop Routing ─────────────────────────────────────────────────── const maxHopsRaw = process.env[`ROUTING_MAX_HOPS_${suffix}`] || @@ -509,6 +585,7 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { secretKey: sep40SecretKey, }, pairs: parseWatchedPairs(rawPairs), + pegged: parsePeggedAssets(rawPegged), routing: { maxHops, intermediates }, facilitator: { secretKey: facilitatorSecretKey, feeStroops: facilitatorFeeStroops }, } @@ -712,6 +789,7 @@ export const config = { get oracle() { return resolveNetwork(activeNetwork).oracle }, get blend() { return resolveNetwork(activeNetwork).blend }, get pairs() { return resolveNetwork(activeNetwork).pairs }, + get pegged() { return resolveNetwork(activeNetwork).pegged }, get routing() { return resolveNetwork(activeNetwork).routing }, get facilitator() { return resolveNetwork(activeNetwork).facilitator }, diff --git a/src/routes/pegs.ts b/src/routes/pegs.ts new file mode 100644 index 00000000..7d128ed8 --- /dev/null +++ b/src/routes/pegs.ts @@ -0,0 +1,233 @@ +import type { FastifyInstance } from 'fastify' +import { pgPool } from '../db' +import { + getNetworkConfig, + activeNetwork, + DEFAULT_PEG_TOLERANCE_BPS, + type NetworkName, + type PeggedAssetConfig, +} from '../config' +import { pegsResponseSchema } from '../api/schemas' +import type { WatchedPair } from '../types' +import '../middleware/network' + +/** + * Default deviation band in basis points (100 bps = 1.0%), aligned with + * GET /snapshot (PEG_TOLERANCE_BPS = 100) and the peg-deviation alerting + * engine (#322, default band = 0.01 = 100 bps). + */ +export const PEG_TOLERANCE_BPS = DEFAULT_PEG_TOLERANCE_BPS ?? 100 + +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', + { config: { scope: 'read:prices' }, 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/snapshot.ts b/src/routes/snapshot.ts index 01ea9a66..d385f9af 100644 --- a/src/routes/snapshot.ts +++ b/src/routes/snapshot.ts @@ -579,11 +579,22 @@ export async function registerSnapshotRoutes(app: FastifyInstance) { samples24h: number lastUpdated: string | null }[] = [] - for (const pair of getNetworkConfig(network).pairs) { + const netConfig = getNetworkConfig(network) + const configuredPegs = netConfig.pegged ?? [] + const matchConfiguredPeg = (asset: { code: string; issuer: string | null }) => + configuredPegs.find( + p => + p.asset.code.toUpperCase() === asset.code.toUpperCase() && + (!p.asset.issuer || !asset.issuer || p.asset.issuer === asset.issuer) + ) + + for (const pair of netConfig.pairs) { for (const side of ['assetA', 'assetB'] as const) { const pegged = pair[side] const quote = side === 'assetA' ? pair.assetB : pair.assetA - const target = PEGGED_ASSETS[pegged.code] + const configuredPeg = matchConfiguredPeg(pegged) + const target = configuredPeg?.target ?? PEGGED_ASSETS[pegged.code] + const targetPrice = configuredPeg?.targetPrice ?? 1 if (!target) continue const agg = pairs.get(pair.pairKey) @@ -604,12 +615,17 @@ export async function registerSnapshotRoutes(app: FastifyInstance) { } // Stored price is assetB-per-assetA. Read as quote-per-pegged so a - // pair quoted directly in its fiat target sits near 1.0: the pegged - // asset is asset A → price is already quote-per-pegged; the pegged - // asset is asset B → invert it. + // pair quoted directly in its fiat target sits near targetPrice: the + // pegged asset is asset A → price is already quote-per-pegged; the + // pegged asset is asset B → invert it. const reading = side === 'assetA' ? agg.price : 1 / agg.price + const quoteConfiguredPeg = matchConfiguredPeg(quote) + const quoteSiblingTarget = + quoteConfiguredPeg && quoteConfiguredPeg.targetPrice === 1 + ? quoteConfiguredPeg.target + : PEGGED_ASSETS[quote.code] const quoteIsReference = - quote.code === target || PEGGED_ASSETS[quote.code] === target + quote.code === target || quoteSiblingTarget === target if (!quoteIsReference) { pegs.push({ @@ -622,7 +638,7 @@ export async function registerSnapshotRoutes(app: FastifyInstance) { continue } - const deviationBp = (reading - 1) * 10_000 + const deviationBp = ((reading - targetPrice) / targetPrice) * 10_000 pegs.push({ ...base, price: reading,