diff --git a/.changeset/snapshot-endpoint.md b/.changeset/snapshot-endpoint.md new file mode 100644 index 00000000..fe397bb4 --- /dev/null +++ b/.changeset/snapshot-endpoint.md @@ -0,0 +1,11 @@ +--- +'lens': minor +--- + +Add `GET /snapshot`: one cached, network-scoped summary of a network's venues +(liveness), AMM liquidity (TVL, per-asset reserves and per-pool detail), 24h +traded volume (per venue, with a single-unit total only when the pairs share a +unit), the busiest pairs with their price direction, stablecoin peg readings, +and data freshness. All figures come from a single database statement with one +`as_of` clock, and unknowns are reported as `null` with a reason rather than +invented. The endpoint is public and never 404s. diff --git a/.env.example b/.env.example index 3937aedc..4e96f05c 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,8 @@ PRICE_POINTS_PRECREATE_DAYS=7 REDIS_URL=redis://localhost:6379 # How long to cache price responses in seconds (default: 10) PRICE_CACHE_TTL=10 +# How long to cache GET /snapshot responses per network in seconds (default: 15) +SNAPSHOT_CACHE_TTL=15 # Default timeout for outbound HTTP requests in milliseconds (default: 10000) HTTP_TIMEOUT_MS=10000 diff --git a/README.md b/README.md index 13334c3b..48c78bd9 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity | GET | `/benchmark/:asset?target=USD` | Peg-deviation statistics versus a target | | 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 | | 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)) | | GET | `/usage/me` | Usage and quota for the calling API key (requires `Authorization: Bearer `) | | GET | `/supported` | Payment kinds and extensions this facilitator supports | diff --git a/openapi.json b/openapi.json index 94cb0a74..b889418f 100644 --- a/openapi.json +++ b/openapi.json @@ -168,6 +168,52 @@ } } }, + "/snapshot": { + "get": { + "operationId": "getSnapshot", + "summary": "Whole-network snapshot (venues, TVL, 24h volume, top pairs, pegs, freshness)", + "description": "A single timestamped summary of one network: every venue's liveness, AMM pool reserves (TVL), 24h traded volume, the busiest pairs, stablecoin peg readings and how fresh the underlying data is. All figures are read in one consistent database statement, so `asOf` is a real instant and every age is measured from it. Missing data is reported as `null` with a reason (or an empty list), never invented — a cross-pair volume total is only returned when the pairs share one unit. Responses are cached briefly per network; `cache.ageSeconds` states the age of a served copy.", + "security": [], + "parameters": [ + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Network snapshot", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + }, + "description": "Freshness/brief-caching policy for the snapshot." + }, + "X-Cache": { + "schema": { + "type": "string", + "enum": [ + "HIT", + "MISS" + ] + }, + "description": "Whether this response came from the short-lived cache." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SnapshotResponse" + } + } + } + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, "/price/{assetA}/{assetB}": { "get": { "operationId": "getPrice", @@ -2192,6 +2238,448 @@ } } }, + "SnapshotAsset": { + "type": "object", + "description": "A Stellar asset. The issuer is kept because a code such as USDC names a different asset per issuer; `issuer` is null only for the native asset.", + "properties": { + "code": { + "type": "string" + }, + "issuer": { + "type": "string", + "nullable": true + } + } + }, + "SnapshotVenue": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "source": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "orderbook", + "amm", + "unknown" + ] + }, + "enabled": { + "type": "boolean", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "reporting", + "stale", + "silent", + "disabled" + ] + }, + "samples": { + "type": "integer" + }, + "samples24h": { + "type": "integer" + }, + "pairs24h": { + "type": "integer" + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "ageSeconds": { + "type": "integer", + "nullable": true + } + } + }, + "SnapshotPool": { + "type": "object", + "properties": { + "poolId": { + "type": "string" + }, + "pairKey": { + "type": "string", + "nullable": true + }, + "assetA": { + "$ref": "#/components/schemas/SnapshotAsset" + }, + "assetB": { + "$ref": "#/components/schemas/SnapshotAsset" + }, + "reserveA": { + "type": "number", + "nullable": true + }, + "reserveB": { + "type": "number", + "nullable": true + }, + "spotPrice": { + "type": "number", + "nullable": true + }, + "feeBp": { + "type": "integer", + "nullable": true + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "ageSeconds": { + "type": "integer", + "nullable": true + } + } + }, + "SnapshotVolume": { + "type": "object", + "properties": { + "value": { + "type": "number", + "nullable": true + }, + "unit": { + "allOf": [ + { + "$ref": "#/components/schemas/SnapshotAsset" + } + ], + "nullable": true + }, + "reason": { + "type": "string", + "nullable": true + } + } + }, + "SnapshotTopPair": { + "type": "object", + "properties": { + "pairKey": { + "type": "string" + }, + "configured": { + "type": "boolean", + "description": "Whether the pair is in this network's watched-pair config. When false the asset order (and therefore the price direction) is taken from the alphabetically-sorted pair key." + }, + "assetA": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/SnapshotAsset" + } + ] + }, + "assetB": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/SnapshotAsset" + } + ] + }, + "price": { + "type": "number", + "nullable": true, + "description": "Stored as assetB-per-assetA in the config's order." + }, + "baseVolume24h": { + "type": "number" + }, + "counterVolume24h": { + "type": "number" + }, + "samples24h": { + "type": "integer" + }, + "venues": { + "type": "array", + "items": { + "type": "string" + } + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "ageSeconds": { + "type": "integer", + "nullable": true + } + } + }, + "SnapshotPeg": { + "type": "object", + "properties": { + "asset": { + "$ref": "#/components/schemas/SnapshotAsset" + }, + "target": { + "type": "string" + }, + "pairKey": { + "type": "string" + }, + "quote": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/SnapshotAsset" + } + ] + }, + "price": { + "type": "number", + "nullable": true, + "description": "The pegged asset's price in its quote asset." + }, + "deviationBp": { + "type": "number", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "on_peg", + "off_peg", + "no_reference", + "no_data" + ] + }, + "reason": { + "type": "string", + "nullable": true + }, + "samples24h": { + "type": "integer" + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "SnapshotResponse": { + "type": "object", + "properties": { + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, + "asOf": { + "type": "string", + "format": "date-time", + "description": "The single instant (from one database statement) every window and age is measured from." + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "window": { + "type": "string", + "enum": [ + "24h" + ] + }, + "cache": { + "type": "object", + "properties": { + "ttlSeconds": { + "type": "integer" + }, + "maxAgeSeconds": { + "type": "integer" + }, + "hit": { + "type": "boolean" + }, + "storedAt": { + "type": "string", + "format": "date-time" + }, + "ageSeconds": { + "type": "integer", + "nullable": true + } + } + }, + "venues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotVenue" + } + }, + "tvl": { + "type": "object", + "properties": { + "note": { + "type": "string" + }, + "poolCount": { + "type": "integer" + }, + "sampleCount": { + "type": "integer" + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "ageSeconds": { + "type": "integer", + "nullable": true + }, + "stale": { + "type": "boolean" + }, + "byAsset": { + "type": "array", + "items": { + "type": "object", + "properties": { + "asset": { + "$ref": "#/components/schemas/SnapshotAsset" + }, + "amount": { + "type": "number" + }, + "pools": { + "type": "integer" + } + } + } + }, + "pools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotPool" + } + } + } + }, + "volume24h": { + "type": "object", + "properties": { + "window": { + "type": "string", + "enum": [ + "24h" + ] + }, + "sampleCount": { + "type": "integer" + }, + "byVenue": { + "type": "array", + "items": { + "type": "object", + "properties": { + "venue": { + "type": "string" + }, + "baseVolume": { + "type": "number" + }, + "counterVolume": { + "type": "number" + }, + "samples": { + "type": "integer" + }, + "pairs": { + "type": "integer" + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "ageSeconds": { + "type": "integer", + "nullable": true + } + } + } + }, + "total": { + "$ref": "#/components/schemas/SnapshotVolume" + } + } + }, + "topPairs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotTopPair" + } + }, + "pegs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotPeg" + } + }, + "freshness": { + "type": "object", + "properties": { + "asOf": { + "type": "string", + "format": "date-time" + }, + "windowStart": { + "type": "string", + "format": "date-time" + }, + "windowEnd": { + "type": "string", + "format": "date-time" + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "ageSeconds": { + "type": "integer", + "nullable": true + }, + "staleAfterSeconds": { + "type": "integer" + }, + "stale": { + "type": "boolean" + }, + "venuesReporting": { + "type": "integer" + }, + "venuesExpected": { + "type": "integer" + }, + "venuesSilent": { + "type": "integer" + } + } + } + } + }, "AggregatedPrice": { "type": "object", "properties": { diff --git a/openapi.yaml b/openapi.yaml index eb49b630..c96dd10a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -117,6 +117,42 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' + /snapshot: + get: + operationId: getSnapshot + summary: Whole-network snapshot (venues, TVL, 24h volume, top pairs, pegs, freshness) + description: >- + A single timestamped summary of one network: every venue's liveness, + AMM pool reserves (TVL), 24h traded volume, the busiest pairs, stablecoin + peg readings and how fresh the underlying data is. All figures are read + in one consistent database statement, so `asOf` is a real instant and + every age is measured from it. Missing data is reported as `null` with a + reason (or an empty list), never invented — a cross-pair volume total is + only returned when the pairs share one unit. Responses are cached briefly + per network; `cache.ageSeconds` states the age of a served copy. + security: [] + parameters: + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Network snapshot + headers: + Cache-Control: + schema: + type: string + description: Freshness/brief-caching policy for the snapshot. + X-Cache: + schema: + type: string + enum: [HIT, MISS] + description: Whether this response came from the short-lived cache. + content: + application/json: + schema: + $ref: '#/components/schemas/SnapshotResponse' + '429': + $ref: '#/components/responses/TooManyRequests' + /price/{assetA}/{assetB}: get: operationId: getPrice @@ -1433,6 +1469,306 @@ components: type: string nullable: true + SnapshotAsset: + type: object + description: >- + A Stellar asset. The issuer is kept because a code such as USDC names a + different asset per issuer; `issuer` is null only for the native asset. + properties: + code: + type: string + issuer: + type: string + nullable: true + + SnapshotVenue: + type: object + properties: + name: + type: string + source: + type: string + type: + type: string + enum: [orderbook, amm, unknown] + enabled: + type: boolean + nullable: true + status: + type: string + enum: [reporting, stale, silent, disabled] + samples: + type: integer + samples24h: + type: integer + pairs24h: + type: integer + lastUpdated: + type: string + format: date-time + nullable: true + ageSeconds: + type: integer + nullable: true + + SnapshotPool: + type: object + properties: + poolId: + type: string + pairKey: + type: string + nullable: true + assetA: + $ref: '#/components/schemas/SnapshotAsset' + assetB: + $ref: '#/components/schemas/SnapshotAsset' + reserveA: + type: number + nullable: true + reserveB: + type: number + nullable: true + spotPrice: + type: number + nullable: true + feeBp: + type: integer + nullable: true + lastUpdated: + type: string + format: date-time + nullable: true + ageSeconds: + type: integer + nullable: true + + SnapshotVolume: + type: object + properties: + value: + type: number + nullable: true + unit: + allOf: + - $ref: '#/components/schemas/SnapshotAsset' + nullable: true + reason: + type: string + nullable: true + + SnapshotTopPair: + type: object + properties: + pairKey: + type: string + configured: + type: boolean + description: >- + Whether the pair is in this network's watched-pair config. When + false the asset order (and therefore the price direction) is taken + from the alphabetically-sorted pair key. + assetA: + nullable: true + allOf: + - $ref: '#/components/schemas/SnapshotAsset' + assetB: + nullable: true + allOf: + - $ref: '#/components/schemas/SnapshotAsset' + price: + type: number + nullable: true + description: Stored as assetB-per-assetA in the config's order. + baseVolume24h: + type: number + counterVolume24h: + type: number + samples24h: + type: integer + venues: + type: array + items: + type: string + lastUpdated: + type: string + format: date-time + nullable: true + ageSeconds: + type: integer + nullable: true + + SnapshotPeg: + type: object + properties: + asset: + $ref: '#/components/schemas/SnapshotAsset' + target: + type: string + pairKey: + type: string + quote: + nullable: true + allOf: + - $ref: '#/components/schemas/SnapshotAsset' + price: + type: number + nullable: true + description: The pegged asset's price in its quote asset. + deviationBp: + type: number + nullable: true + status: + type: string + enum: [on_peg, off_peg, no_reference, no_data] + reason: + type: string + nullable: true + samples24h: + type: integer + lastUpdated: + type: string + format: date-time + nullable: true + + SnapshotResponse: + type: object + properties: + network: + type: string + enum: [testnet, mainnet] + asOf: + type: string + format: date-time + description: The single instant (from one database statement) every window and age is measured from. + generatedAt: + type: string + format: date-time + window: + type: string + enum: ['24h'] + cache: + type: object + properties: + ttlSeconds: + type: integer + maxAgeSeconds: + type: integer + hit: + type: boolean + storedAt: + type: string + format: date-time + ageSeconds: + type: integer + nullable: true + venues: + type: array + items: + $ref: '#/components/schemas/SnapshotVenue' + tvl: + type: object + properties: + note: + type: string + poolCount: + type: integer + sampleCount: + type: integer + lastUpdated: + type: string + format: date-time + nullable: true + ageSeconds: + type: integer + nullable: true + stale: + type: boolean + byAsset: + type: array + items: + type: object + properties: + asset: + $ref: '#/components/schemas/SnapshotAsset' + amount: + type: number + pools: + type: integer + pools: + type: array + items: + $ref: '#/components/schemas/SnapshotPool' + volume24h: + type: object + properties: + window: + type: string + enum: ['24h'] + sampleCount: + type: integer + byVenue: + type: array + items: + type: object + properties: + venue: + type: string + baseVolume: + type: number + counterVolume: + type: number + samples: + type: integer + pairs: + type: integer + lastUpdated: + type: string + format: date-time + nullable: true + ageSeconds: + type: integer + nullable: true + total: + $ref: '#/components/schemas/SnapshotVolume' + topPairs: + type: array + items: + $ref: '#/components/schemas/SnapshotTopPair' + pegs: + type: array + items: + $ref: '#/components/schemas/SnapshotPeg' + freshness: + type: object + properties: + asOf: + type: string + format: date-time + windowStart: + type: string + format: date-time + windowEnd: + type: string + format: date-time + lastUpdated: + type: string + format: date-time + nullable: true + ageSeconds: + type: integer + nullable: true + staleAfterSeconds: + type: integer + stale: + type: boolean + venuesReporting: + type: integer + venuesExpected: + type: integer + venuesSilent: + type: integer + AggregatedPrice: type: object properties: diff --git a/src/__tests__/snapshot.test.ts b/src/__tests__/snapshot.test.ts new file mode 100644 index 00000000..8b49751e --- /dev/null +++ b/src/__tests__/snapshot.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import Ajv from 'ajv' + +const { mockQuery, mockGetCachedSnapshot, mockSetCachedSnapshot, configState } = vi.hoisted(() => ({ + mockQuery: vi.fn(), + mockGetCachedSnapshot: vi.fn(), + mockSetCachedSnapshot: vi.fn(), + configState: { + active: 'testnet', + pairs: {} as Record, + }, +})) + +vi.mock('../db', () => ({ pgPool: { query: mockQuery } })) + +vi.mock('../redis', () => ({ + getCachedSnapshot: mockGetCachedSnapshot, + setCachedSnapshot: mockSetCachedSnapshot, +})) + +vi.mock('../config', () => ({ + activeNetwork: configState.active, + getNetworkConfig: (network: string) => ({ + pairs: configState.pairs[network] ?? [], + soroswap: { enabled: true }, + aquarius: { enabled: network === 'mainnet' }, + }), + config: { cache: { snapshotTtl: 15 } }, +})) + +import { registerSnapshotRoutes } from '../routes/snapshot' +import { snapshotResponseSchema } from '../api/schemas' + +const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' +const USDT_ISSUER = 'GCQTGZRCLVOJWLAW3P5OTFXPCD3HH4HAJ5DVVQMVJ4BQ7ZOBHYP3F7M' + +const PAIR_KEY = `USDC:${USDC_ISSUER}/XLM` + +const pairUsdcXlm = { + assetA: { code: 'USDC', issuer: USDC_ISSUER }, + assetB: { code: 'XLM', issuer: null }, + pairKey: PAIR_KEY, +} +const pairUsdcUsdt = { + assetA: { code: 'USDC', issuer: USDC_ISSUER }, + assetB: { code: 'USDT', issuer: USDT_ISSUER }, + pairKey: `USDC:${USDC_ISSUER}/USDT:${USDT_ISSUER}`, +} + +/** The row shape a single CTE query returns: one row, all sections as JSON. */ +function dbRow(overrides: Record = {}) { + return { + rows: [ + { + as_of: new Date('2026-10-17T12:00:00.000Z'), + venues: [], + series: [], + latest: [], + pools: [], + ...overrides, + }, + ], + } +} + +const populated = { + venues: [ + { source: 'SDEX', samples: 50, last_seen: '2026-10-17T11:59:50.000Z' }, + { source: 'AMM', samples: 20, last_seen: '2026-10-17T11:59:00.000Z' }, + ], + series: [ + { + source: 'SDEX', + pair_key: PAIR_KEY, + samples_24h: 40, + base_volume_24h: 1000, + counter_volume_24h: 5500, + last_seen_24h: '2026-10-17T11:59:50.000Z', + }, + { + source: 'AMM', + pair_key: PAIR_KEY, + samples_24h: 15, + base_volume_24h: 400, + counter_volume_24h: 2200, + last_seen_24h: '2026-10-17T11:59:00.000Z', + }, + ], + latest: [{ pair_key: PAIR_KEY, price: 5.5, last_seen: '2026-10-17T11:59:50.000Z' }], +} + +async function buildApp() { + const app = Fastify({ logger: false }) + await registerSnapshotRoutes(app) + await app.ready() + return app +} + +function validateSchema(body: unknown) { + const ajv = new Ajv({ allErrors: true }) + const validate = ajv.compile(snapshotResponseSchema as object) + const ok = validate(body) + expect(ok, ajv.errorsText(validate.errors)).toBe(true) +} + +beforeEach(() => { + vi.clearAllMocks() + mockGetCachedSnapshot.mockResolvedValue(null) + mockSetCachedSnapshot.mockResolvedValue(undefined) + configState.pairs = { + testnet: [pairUsdcXlm], + mainnet: [pairUsdcXlm], + } +}) + +describe('GET /snapshot', () => { + it('returns the whole-network summary in one consistent read', async () => { + mockQuery.mockResolvedValue(dbRow(populated)) + + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/snapshot' }) + const body = res.json() + + expect(res.statusCode).toBe(200) + expect(body.network).toBe('testnet') + expect(body.asOf).toBe('2026-10-17T12:00:00.000Z') + expect(body.window).toBe('24h') + expect(body.cache).toEqual({ + ttlSeconds: 15, + maxAgeSeconds: 15, + hit: false, + storedAt: '2026-10-17T12:00:00.000Z', + ageSeconds: 0, + }) + + // One statement, scoped to the network, is what makes `asOf` trustworthy. + expect(mockQuery).toHaveBeenCalledTimes(1) + expect(mockQuery.mock.calls[0][1]).toEqual(['testnet']) + expect(mockQuery.mock.calls[0][0]).toMatch(/FROM price_points[\s\S]*network = \$1/) + + validateSchema(body) + await app.close() + }) + + it('reports every venue, including the enabled-but-silent one', async () => { + mockQuery.mockResolvedValue(dbRow(populated)) + const app = await buildApp() + const body = (await app.inject({ method: 'GET', url: '/snapshot' })).json() + + expect(body.venues).toEqual([ + { + name: 'SDEX', + source: 'SDEX', + type: 'orderbook', + enabled: true, + status: 'reporting', + samples: 50, + samples24h: 40, + pairs24h: 1, + lastUpdated: '2026-10-17T11:59:50.000Z', + ageSeconds: 10, + }, + { + name: 'AMM', + source: 'AMM', + type: 'amm', + enabled: true, + status: 'reporting', + samples: 20, + samples24h: 15, + pairs24h: 1, + lastUpdated: '2026-10-17T11:59:00.000Z', + ageSeconds: 60, + }, + { + name: 'Soroswap', + source: 'soroswap_amm', + type: 'amm', + enabled: true, + status: 'silent', + samples: 0, + samples24h: 0, + pairs24h: 0, + lastUpdated: null, + ageSeconds: null, + }, + { + name: 'Aquarius', + source: 'aquarius_amm', + type: 'amm', + enabled: false, + status: 'disabled', + samples: 0, + samples24h: 0, + pairs24h: 0, + lastUpdated: null, + ageSeconds: null, + }, + ]) + await app.close() + }) + + it('sums volume per venue and returns a single-unit total', async () => { + mockQuery.mockResolvedValue(dbRow(populated)) + const app = await buildApp() + const body = (await app.inject({ method: 'GET', url: '/snapshot' })).json() + + expect(body.volume24h.window).toBe('24h') + expect(body.volume24h.sampleCount).toBe(55) + expect(body.volume24h.byVenue).toEqual([ + { + venue: 'AMM', + baseVolume: 400, + counterVolume: 2200, + samples: 15, + pairs: 1, + lastUpdated: '2026-10-17T11:59:00.000Z', + ageSeconds: 60, + }, + { + venue: 'SDEX', + baseVolume: 1000, + counterVolume: 5500, + samples: 40, + pairs: 1, + lastUpdated: '2026-10-17T11:59:50.000Z', + ageSeconds: 10, + }, + ]) + expect(body.volume24h.total).toEqual({ + value: 1400, + unit: { code: 'USDC', issuer: USDC_ISSUER }, + reason: null, + }) + await app.close() + }) + + it('reports a null total with a reason when pairs do not share a unit', async () => { + configState.pairs.testnet = [ + pairUsdcXlm, + { + assetA: { code: 'BTC', issuer: 'GBTC' }, + assetB: { code: 'EUR', issuer: 'GEUR' }, + pairKey: 'BTC:GBTC/EUR:GEUR', + }, + ] + mockQuery.mockResolvedValue( + dbRow({ + series: [ + { source: 'SDEX', pair_key: PAIR_KEY, samples_24h: 3, base_volume_24h: 10, counter_volume_24h: 55, last_seen_24h: '2026-10-17T11:59:00.000Z' }, + { source: 'SDEX', pair_key: 'BTC:GBTC/EUR:GEUR', samples_24h: 2, base_volume_24h: 1, counter_volume_24h: 50000, last_seen_24h: '2026-10-17T11:59:00.000Z' }, + ], + latest: [{ pair_key: PAIR_KEY, price: 5.5, last_seen: '2026-10-17T11:59:00.000Z' }], + }), + ) + const app = await buildApp() + const body = (await app.inject({ method: 'GET', url: '/snapshot' })).json() + + expect(body.volume24h.total.value).toBeNull() + expect(body.volume24h.total.unit).toBeNull() + expect(body.volume24h.total.reason).toMatch(/base and .* counter assets/) + await app.close() + }) + + it('ranks top pairs, keeping the issuer and the price direction', async () => { + mockQuery.mockResolvedValue(dbRow(populated)) + const app = await buildApp() + const body = (await app.inject({ method: 'GET', url: '/snapshot' })).json() + + expect(body.topPairs[0]).toEqual({ + pairKey: PAIR_KEY, + configured: true, + assetA: { code: 'USDC', issuer: USDC_ISSUER }, + assetB: { code: 'XLM', issuer: null }, + price: 5.5, + baseVolume24h: 1400, + counterVolume24h: 7700, + samples24h: 55, + venues: ['AMM', 'SDEX'], + lastUpdated: '2026-10-17T11:59:50.000Z', + ageSeconds: 10, + }) + await app.close() + }) + + it('reports TVL as per-asset reserves with pool detail', async () => { + mockQuery.mockResolvedValue( + dbRow({ + pools: [ + { + pool_id: 'CPOOL', + asset_a: 'USDC', + asset_b: 'XLM', + reserve_a: 1234.5, + reserve_b: 6789, + spot_price: 5.5, + fee_bp: 30, + timestamp: '2026-10-17T11:58:00.000Z', + pair_key: PAIR_KEY, + }, + ], + }), + ) + const app = await buildApp() + const body = (await app.inject({ method: 'GET', url: '/snapshot' })).json() + + expect(body.tvl.poolCount).toBe(1) + expect(body.tvl.stale).toBe(false) + expect(body.tvl.byAsset).toEqual([ + { asset: { code: 'XLM', issuer: null }, amount: 6789, pools: 1 }, + { asset: { code: 'USDC', issuer: USDC_ISSUER }, amount: 1234.5, pools: 1 }, + ]) + expect(body.tvl.pools[0]).toMatchObject({ + poolId: 'CPOOL', + pairKey: PAIR_KEY, + assetA: { code: 'USDC', issuer: USDC_ISSUER }, + assetB: { code: 'XLM', issuer: null }, + reserveA: 1234.5, + reserveB: 6789, + spotPrice: 5.5, + feeBp: 30, + }) + await app.close() + }) + + it('summarises freshness from the single as_of clock', async () => { + mockQuery.mockResolvedValue(dbRow(populated)) + const app = await buildApp() + const body = (await app.inject({ method: 'GET', url: '/snapshot' })).json() + + expect(body.freshness).toEqual({ + asOf: '2026-10-17T12:00:00.000Z', + windowStart: '2026-10-16T12:00:00.000Z', + windowEnd: '2026-10-17T12:00:00.000Z', + lastUpdated: '2026-10-17T11:59:50.000Z', + ageSeconds: 10, + staleAfterSeconds: 300, + stale: false, + venuesReporting: 2, + venuesExpected: 3, + venuesSilent: 1, + }) + await app.close() + }) + + describe('pegs', () => { + it('measures a stablecoin against a fiat-target sibling', async () => { + configState.pairs.testnet = [pairUsdcUsdt] + mockQuery.mockResolvedValue( + dbRow({ + series: [ + { source: 'SDEX', pair_key: pairUsdcUsdt.pairKey, samples_24h: 12, base_volume_24h: 500, counter_volume_24h: 500, last_seen_24h: '2026-10-17T11:59:00.000Z' }, + ], + latest: [{ pair_key: pairUsdcUsdt.pairKey, price: 1.0005, last_seen: '2026-10-17T11:59:00.000Z' }], + }), + ) + const app = await buildApp() + const body = (await app.inject({ method: 'GET', url: '/snapshot' })).json() + + const peg = body.pegs.find((p: { asset: { code: string } }) => p.asset.code === 'USDC') + expect(peg.target).toBe('USD') + expect(peg.status).toBe('on_peg') + expect(peg.price).toBeCloseTo(1.0005) + expect(peg.deviationBp).toBeCloseTo(5) + await app.close() + }) + + it('flags an off-peg reading beyond the tolerance', async () => { + configState.pairs.testnet = [pairUsdcUsdt] + mockQuery.mockResolvedValue( + dbRow({ + latest: [{ pair_key: pairUsdcUsdt.pairKey, price: 1.05, last_seen: '2026-10-17T11:59:00.000Z' }], + }), + ) + const app = await buildApp() + const body = (await app.inject({ method: 'GET', url: '/snapshot' })).json() + + const peg = body.pegs.find((p: { asset: { code: string } }) => p.asset.code === 'USDC') + expect(peg.status).toBe('off_peg') + expect(peg.deviationBp).toBeGreaterThan(100) + await app.close() + }) + + it('says no_reference when quoted in an asset that is not the target', async () => { + mockQuery.mockResolvedValue(dbRow(populated)) + const app = await buildApp() + const body = (await app.inject({ method: 'GET', url: '/snapshot' })).json() + + const peg = body.pegs.find((p: { asset: { code: string } }) => p.asset.code === 'USDC') + expect(peg.status).toBe('no_reference') + expect(peg.deviationBp).toBeNull() + expect(peg.reason).toMatch(/not USD/) + await app.close() + }) + + it('says no_data when the pair has no observed price', async () => { + mockQuery.mockResolvedValue(dbRow()) + const app = await buildApp() + const body = (await app.inject({ method: 'GET', url: '/snapshot' })).json() + + const peg = body.pegs.find((p: { asset: { code: string } }) => p.asset.code === 'USDC') + expect(peg.status).toBe('no_data') + expect(peg.price).toBeNull() + await app.close() + }) + }) + + it('is honest, not empty-handed, when nothing has been ingested yet', async () => { + mockQuery.mockResolvedValue(dbRow()) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/snapshot' }) + const body = res.json() + + expect(res.statusCode).toBe(200) + expect(body.topPairs).toEqual([]) + expect(body.volume24h.total).toEqual({ + value: null, + unit: null, + reason: 'no trades in the last 24h', + }) + expect(body.tvl.poolCount).toBe(0) + expect(body.freshness.lastUpdated).toBeNull() + expect(body.freshness.stale).toBe(true) + validateSchema(body) + await app.close() + }) + + describe('caching', () => { + it('serves a cache hit without touching the database, but republishing its age', async () => { + const storedAt = new Date(Date.now() - 4000).toISOString() + mockGetCachedSnapshot.mockResolvedValue( + JSON.stringify({ + network: 'testnet', + asOf: storedAt, + generatedAt: storedAt, + window: '24h', + cache: { ttlSeconds: 15, maxAgeSeconds: 15, hit: false, storedAt, ageSeconds: 0 }, + venues: [], + tvl: { note: '', poolCount: 0, sampleCount: 0, lastUpdated: null, ageSeconds: null, stale: false, byAsset: [], pools: [] }, + volume24h: { window: '24h', sampleCount: 0, byVenue: [], total: { value: null, unit: null, reason: 'no trades in the last 24h' } }, + topPairs: [], + pegs: [], + freshness: { + asOf: storedAt, + windowStart: storedAt, + windowEnd: storedAt, + lastUpdated: null, + ageSeconds: null, + staleAfterSeconds: 300, + stale: true, + venuesReporting: 0, + venuesExpected: 0, + venuesSilent: 0, + }, + }), + ) + + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/snapshot' }) + const body = res.json() + + expect(res.headers['x-cache']).toBe('HIT') + expect(res.headers['cache-control']).toBe('public, max-age=15') + expect(body.cache.hit).toBe(true) + expect(body.cache.ageSeconds).toBeGreaterThanOrEqual(3) + expect(mockQuery).not.toHaveBeenCalled() + await app.close() + }) + + it('stores a miss under the network key with the configured TTL', async () => { + mockQuery.mockResolvedValue(dbRow(populated)) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/snapshot' }) + + expect(res.headers['x-cache']).toBe('MISS') + expect(mockSetCachedSnapshot).toHaveBeenCalledWith('testnet', expect.objectContaining({ network: 'testnet' }), 15) + await app.close() + }) + }) + + describe('network scoping', () => { + it('queries and echoes the requested network', async () => { + mockQuery.mockResolvedValue(dbRow(populated)) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/snapshot?network=mainnet' }) + + expect(res.json().network).toBe('mainnet') + expect(mockQuery.mock.calls[0][1]).toEqual(['mainnet']) + await app.close() + }) + + it('rejects an unknown network with 400 and issues no query', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/snapshot?network=futurenet' }) + + expect(res.statusCode).toBe(400) + expect(res.json().error).toMatch(/network must be one of/) + expect(mockQuery).not.toHaveBeenCalled() + await app.close() + }) + }) + + it('returns 500 when the aggregation query fails', async () => { + mockQuery.mockRejectedValue(new Error('db down')) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/snapshot' }) + + expect(res.statusCode).toBe(500) + expect(res.json().error).toMatch(/Snapshot aggregation failed/) + await app.close() + }) +}) diff --git a/src/api/schemas.ts b/src/api/schemas.ts index c7db8496..4e68bc98 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -318,6 +318,299 @@ export const benchmarkResponseSchema = { }, } as const +// An asset identifier, shared by the /snapshot sub-objects below. `issuer` is +// null only for the native asset (XLM); every issued asset keeps its issuer, +// since the same code from a different issuer is a different asset. +const assetIdSchema = { + type: 'object', + required: ['code', 'issuer'], + additionalProperties: false, + properties: { + code: { type: 'string' }, + issuer: { type: ['string', 'null'] }, + }, +} as const + +/** + * GET /snapshot + * + * The whole-network summary. `additionalProperties: false` throughout so an + * accidental new field fails validation in dev/test rather than shipping + * silently; nullable numbers/strings are explicit, because the endpoint + * reports "unknown" as null with a reason instead of inventing a value. + */ +export const snapshotResponseSchema = { + type: 'object', + required: [ + 'network', + 'asOf', + 'generatedAt', + 'window', + 'cache', + 'venues', + 'tvl', + 'volume24h', + 'topPairs', + 'pegs', + 'freshness', + ], + additionalProperties: false, + properties: { + network: { type: 'string', enum: ['testnet', 'mainnet'] }, + asOf: { type: 'string' }, + generatedAt: { type: 'string' }, + window: { type: 'string', enum: ['24h'] }, + cache: { + type: 'object', + required: ['ttlSeconds', 'maxAgeSeconds', 'hit', 'storedAt', 'ageSeconds'], + additionalProperties: false, + properties: { + ttlSeconds: { type: 'integer' }, + maxAgeSeconds: { type: 'integer' }, + hit: { type: 'boolean' }, + storedAt: { type: 'string' }, + ageSeconds: { type: ['integer', 'null'] }, + }, + }, + venues: { + type: 'array', + items: { + type: 'object', + required: [ + 'name', + 'source', + 'type', + 'enabled', + 'status', + 'samples', + 'samples24h', + 'pairs24h', + 'lastUpdated', + 'ageSeconds', + ], + additionalProperties: false, + properties: { + name: { type: 'string' }, + source: { type: 'string' }, + type: { type: 'string', enum: ['orderbook', 'amm', 'unknown'] }, + enabled: { type: ['boolean', 'null'] }, + status: { type: 'string', enum: ['reporting', 'stale', 'silent', 'disabled'] }, + samples: { type: 'integer' }, + samples24h: { type: 'integer' }, + pairs24h: { type: 'integer' }, + lastUpdated: { type: ['string', 'null'] }, + ageSeconds: { type: ['integer', 'null'] }, + }, + }, + }, + tvl: { + type: 'object', + required: [ + 'note', + 'poolCount', + 'sampleCount', + 'lastUpdated', + 'ageSeconds', + 'stale', + 'byAsset', + 'pools', + ], + additionalProperties: false, + properties: { + note: { type: 'string' }, + poolCount: { type: 'integer' }, + sampleCount: { type: 'integer' }, + lastUpdated: { type: ['string', 'null'] }, + ageSeconds: { type: ['integer', 'null'] }, + stale: { type: 'boolean' }, + byAsset: { + type: 'array', + items: { + type: 'object', + required: ['asset', 'amount', 'pools'], + additionalProperties: false, + properties: { + asset: assetIdSchema, + amount: { type: 'number' }, + pools: { type: 'integer' }, + }, + }, + }, + pools: { + type: 'array', + items: { + type: 'object', + required: [ + 'poolId', + 'pairKey', + 'assetA', + 'assetB', + 'reserveA', + 'reserveB', + 'spotPrice', + 'feeBp', + 'lastUpdated', + 'ageSeconds', + ], + additionalProperties: false, + properties: { + poolId: { type: 'string' }, + pairKey: { type: ['string', 'null'] }, + assetA: assetIdSchema, + assetB: assetIdSchema, + reserveA: { type: ['number', 'null'] }, + reserveB: { type: ['number', 'null'] }, + spotPrice: { type: ['number', 'null'] }, + feeBp: { type: ['integer', 'null'] }, + lastUpdated: { type: ['string', 'null'] }, + ageSeconds: { type: ['integer', 'null'] }, + }, + }, + }, + }, + }, + volume24h: { + type: 'object', + required: ['window', 'sampleCount', 'byVenue', 'total'], + additionalProperties: false, + properties: { + window: { type: 'string', enum: ['24h'] }, + sampleCount: { type: 'integer' }, + byVenue: { + type: 'array', + items: { + type: 'object', + required: [ + 'venue', + 'baseVolume', + 'counterVolume', + 'samples', + 'pairs', + 'lastUpdated', + 'ageSeconds', + ], + additionalProperties: false, + properties: { + venue: { type: 'string' }, + baseVolume: { type: 'number' }, + counterVolume: { type: 'number' }, + samples: { type: 'integer' }, + pairs: { type: 'integer' }, + lastUpdated: { type: ['string', 'null'] }, + ageSeconds: { type: ['integer', 'null'] }, + }, + }, + }, + total: { + type: 'object', + required: ['value', 'unit', 'reason'], + additionalProperties: false, + properties: { + value: { type: ['number', 'null'] }, + unit: { ...assetIdSchema, type: ['object', 'null'] }, + reason: { type: ['string', 'null'] }, + }, + }, + }, + }, + topPairs: { + type: 'array', + items: { + type: 'object', + required: [ + 'pairKey', + 'configured', + 'assetA', + 'assetB', + 'price', + 'baseVolume24h', + 'counterVolume24h', + 'samples24h', + 'venues', + 'lastUpdated', + 'ageSeconds', + ], + additionalProperties: false, + properties: { + pairKey: { type: 'string' }, + configured: { type: 'boolean' }, + assetA: { ...assetIdSchema, type: ['object', 'null'] }, + assetB: { ...assetIdSchema, type: ['object', 'null'] }, + price: { type: ['number', 'null'] }, + baseVolume24h: { type: 'number' }, + counterVolume24h: { type: 'number' }, + samples24h: { type: 'integer' }, + venues: { type: 'array', items: { type: 'string' } }, + lastUpdated: { type: ['string', 'null'] }, + ageSeconds: { type: ['integer', 'null'] }, + }, + }, + }, + pegs: { + type: 'array', + items: { + type: 'object', + required: [ + 'asset', + 'target', + 'pairKey', + 'quote', + 'price', + 'deviationBp', + 'status', + 'reason', + 'samples24h', + 'lastUpdated', + ], + additionalProperties: false, + properties: { + asset: assetIdSchema, + target: { type: 'string' }, + pairKey: { type: 'string' }, + quote: { ...assetIdSchema, type: ['object', 'null'] }, + price: { type: ['number', 'null'] }, + deviationBp: { type: ['number', 'null'] }, + status: { + type: 'string', + enum: ['on_peg', 'off_peg', 'no_reference', 'no_data'], + }, + reason: { type: ['string', 'null'] }, + samples24h: { type: 'integer' }, + lastUpdated: { type: ['string', 'null'] }, + }, + }, + }, + freshness: { + type: 'object', + required: [ + 'asOf', + 'windowStart', + 'windowEnd', + 'lastUpdated', + 'ageSeconds', + 'staleAfterSeconds', + 'stale', + 'venuesReporting', + 'venuesExpected', + 'venuesSilent', + ], + additionalProperties: false, + properties: { + asOf: { type: 'string' }, + windowStart: { type: 'string' }, + windowEnd: { type: 'string' }, + lastUpdated: { type: ['string', 'null'] }, + ageSeconds: { type: ['integer', 'null'] }, + staleAfterSeconds: { type: 'integer' }, + stale: { type: 'boolean' }, + venuesReporting: { type: 'integer' }, + venuesExpected: { type: 'integer' }, + venuesSilent: { type: 'integer' }, + }, + }, + }, +} as const + /** * Install response-shape validation on a Fastify instance. * diff --git a/src/app.ts b/src/app.ts index 671dac37..32dac100 100644 --- a/src/app.ts +++ b/src/app.ts @@ -25,6 +25,7 @@ import { registerBenchmarkRoutes } from './routes/benchmark' import { registerOracleRoutes } from './routes/oracle' import { registerBasketRoutes } from './routes/basket' import { registerDiscoveryRoutes } from './routes/discovery' +import { registerSnapshotRoutes } from './routes/snapshot' import { registerVenueRoutes } from './routes/venues' import { registerSettleRoute } from './routes/facilitator' import { registerCachePolicy } from './middleware/cachePolicy' @@ -151,6 +152,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { + try { + return await redis.get(`lens:snapshot:${network}`) + } catch { + return null + } +} + +export async function setCachedSnapshot(network: NetworkName, data: object, ttlSeconds: number): Promise { + try { + await redis.set(`lens:snapshot:${network}`, JSON.stringify(data), 'EX', ttlSeconds) + } catch { + // Redis cache miss is non-fatal + } +} diff --git a/src/routes/snapshot.ts b/src/routes/snapshot.ts new file mode 100644 index 00000000..a427c536 --- /dev/null +++ b/src/routes/snapshot.ts @@ -0,0 +1,686 @@ +import type { FastifyInstance } from 'fastify' +import { pgPool } from '../db' +import { getCachedSnapshot, setCachedSnapshot } from '../redis' +import { config, getNetworkConfig, activeNetwork, type NetworkName } from '../config' +import { snapshotResponseSchema } from '../api/schemas' +import type { AssetId, WatchedPair } from '../types' + +/** + * Register the whole-network snapshot endpoint. + * + * GET /snapshot?network=testnet|mainnet + * + * One call that answers "what does this network look like right now": every + * venue's liveness, AMM liquidity (TVL), 24h volume, the busiest pairs, the + * stablecoin peg readings and how fresh the underlying data is — as a single + * timestamped object rather than four or five round-trips. + * + * Design rules, all in service of not lying: + * + * - Everything is read in ONE SQL statement with a single `as_of` timestamp + * taken from the database. Four separate queries could land on four + * different clocks, and then the response would describe an instant that + * never existed. Every 24h cutoff and every `ageSeconds` is measured from + * that same `as_of`. + * - Scoped to exactly one network. Testnet and mainnet rows pooled together + * are not a market; `network` defaults to this process's own network and + * the resolved value is echoed back. + * - Absent data is reported, never invented. A venue with no rows is listed + * as silent, a pair with no trades in the window gets no volume figure, + * the peg check says `no_reference` when it has nothing to compare against, + * and a cross-pair volume total is null — with a reason — when the pairs do + * not share a unit. The `provenance`/`note` fields carry the same honesty. + * - Asset identity is never elided. Where full ids are known (they are, from + * the pair_config's watched pairs / the price-point pair key) the issuer is + * included, because a coin called "USDC" is a different asset per issuer. + * - Always 200. This endpoint summarises state; "no data yet" is state, not a + * client error, and a summary that 404s is useless to a dashboard. + * + * Cached per network for a few seconds so a dashboard polling it cannot turn + * into a full table scan per request. The cached whole response carries its own + * `cache.ageSeconds`, so a hit never pretends to be fresher than it is. + */ + +// How many pairs the snapshot returns in `topPairs`, ranked by their own +// base-asset 24h volume. +const TOP_PAIRS_LIMIT = 10 + +// How long a venue may go without a write before the snapshot calls it stale. +// Generous relative to the ingester poll interval (seconds) so a normal gap +// between polls, or a venue that simply trades rarely, is not flagged. +const STALE_AFTER_SECONDS = 300 + +// A pair of assets whose value is meant to hold at 1:1 against a fiat target. +// Several issuers exist per code on Stellar; the *code* is what a peg is +// defined against, so the check is keyed on the code and the full asset id is +// still reported back for the entry. Anything not listed here gets +// `no_reference` — the snapshot never guesses that an asset is a stablecoin. +const PEGGED_ASSETS: Readonly> = { + USDC: 'USD', + USDT: 'USD', + DAI: 'USD', + USDP: 'USD', + PYUSD: 'USD', + TUSD: 'USD', + USDY: 'USD', + EURC: 'EUR', + EURT: 'EUR', + EUROC: 'EUR', +} + +// A peg that is off by more than this many basis points is reported as +// off_peg. Published in the response so a caller can reinterpret it. +const PEG_TOLERANCE_BPS = 100 + +interface VenueDescriptor { + name: string + source: string + type: 'orderbook' | 'amm' | 'unknown' + enabled: boolean | null +} + +/** The venue surface this network is expected to ingest, mirroring the venue + * registry (src/ingest/venues) without importing its adapters: the snapshot + * only needs the name/source and whether the network enables it. Sources seen + * in the database but not listed here are still reported (type `unknown`), so + * a new venue is never silently omitted. */ +function venueDescriptors(network: NetworkName): VenueDescriptor[] { + const net = getNetworkConfig(network) + return [ + { name: 'SDEX', source: 'SDEX', type: 'orderbook', enabled: true }, + { name: 'AMM', source: 'AMM', type: 'amm', enabled: true }, + { name: 'Soroswap', source: 'soroswap_amm', type: 'amm', enabled: net.soroswap.enabled }, + { name: 'Aquarius', source: 'aquarius_amm', type: 'amm', enabled: net.aquarius.enabled }, + ] +} + +function assetKey(a: AssetId): string { + return `${a.code}:${a.issuer ?? 'native'}` +} + +function assetObj(a: AssetId): { code: string; issuer: string | null } { + return { code: a.code, issuer: a.issuer } +} + +function num(v: unknown): number | null { + if (v == null) return null + const n = typeof v === 'number' ? v : parseFloat(String(v)) + return Number.isFinite(n) ? n : null +} + +function iso(v: unknown): string | null { + if (v == null) return null + const d = v instanceof Date ? v : new Date(String(v)) + return Number.isFinite(d.getTime()) ? d.toISOString() : null +} + +function ageSeconds(asOfMs: number, timestamp: string | null): number | null { + if (!timestamp) return null + const t = Date.parse(timestamp) + return Number.isFinite(t) ? Math.max(0, Math.round((asOfMs - t) / 1000)) : null +} + +/** Parse one asset from a pair-key segment (`CODE` or `CODE:ISSUER`). */ +function parseAssetSegment(s: string): AssetId { + const [code, issuer] = s.split(':') + return { + code: (code ?? '').toUpperCase(), + issuer: issuer && issuer.toLowerCase() !== 'native' ? issuer : null, + } +} + +/** Split an alphabetically-sorted pair key back into its two assets. */ +function parsePairKey(pairKey: string): { assetA: AssetId; assetB: AssetId } | null { + const parts = pairKey.split('/') + if (parts.length !== 2) return null + return { assetA: parseAssetSegment(parts[0]), assetB: parseAssetSegment(parts[1]) } +} + +/** Resolve a stored row's pair to the config's asset order (and identity). The + * stored `price` is assetB-per-assetA in that order; a pair missing from the + * config falls back to the alphabetically-sorted key order and is marked as + * unconfigured so the caller knows the direction was inferred, not declared. */ +function resolvePair( + pairIndex: Map, + pairKey: string, +): { assetA: AssetId; assetB: AssetId; configured: boolean } | null { + const cfg = pairIndex.get(pairKey) + if (cfg) return { assetA: cfg.assetA, assetB: cfg.assetB, configured: true } + const parsed = parsePairKey(pairKey) + if (!parsed) return null + return { ...parsed, configured: false } +} + +/** + * Reduce a set of per-pair base/counter amounts to one figure in one unit, or + * refuse. A total is only honest when every pair shares the same denomination + * on one side: one distinct base asset admits summing the base amounts, else one + * distinct counter asset admits the counter amounts. Two bases *and* two + * counters (e.g. XLM/USDC and BTC/USDC would actually collapse on USDC — but + * XLM/USDC and BTC/EUR would not) yields a null with the reason spelled out. + */ +function collapseVolume( + rows: { assetA: AssetId; assetB: AssetId; base: number; counter: number }[], +): { value: number | null; unit: { code: string; issuer: string | null } | null; reason: string | null } { + if (rows.length === 0) { + return { value: null, unit: null, reason: 'no trades in the last 24h' } + } + const bases = new Map() + const counters = new Map() + for (const r of rows) { + bases.set(assetKey(r.assetA), r.assetA) + counters.set(assetKey(r.assetB), r.assetB) + } + if (bases.size === 1) { + const [asset] = bases.values() + const value = rows.reduce((s, r) => s + r.base, 0) + return { value, unit: assetObj(asset), reason: null } + } + if (counters.size === 1) { + const [asset] = counters.values() + const value = rows.reduce((s, r) => s + r.counter, 0) + return { value, unit: assetObj(asset), reason: null } + } + return { + value: null, + unit: null, + reason: `pairs span ${bases.size} base and ${counters.size} counter assets; a single-unit total is not defined`, + } +} + +interface SeriesRow { + source: string + pair_key: string + samples_24h: number | null + base_volume_24h: number | null + counter_volume_24h: number | null + last_seen_24h: unknown +} +interface LatestRow { + pair_key: string + price: number | null + last_seen: unknown +} +interface VenueLifetimeRow { + source: string + samples: number | null + last_seen: unknown +} +interface PoolRow { + pool_id: string + asset_a: string + asset_b: string + reserve_a: number | null + reserve_b: number | null + spot_price: number | null + fee_bp: number | null + timestamp: unknown + pair_key: string | null +} + +export async function registerSnapshotRoutes(app: FastifyInstance) { + app.get<{ Querystring: { network?: string } }>( + '/snapshot', + { + // A public, cheap, pollable summary. No API key: a market overview that + // requires auth is not a health/observability surface. (x402 does not + // gate this path.) + config: { public: true }, + schema: { response: { 200: snapshotResponseSchema } }, + }, + 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 = (requested as NetworkName) ?? activeNetwork + const ttl = config.cache.snapshotTtl + + // ── Cache ──────────────────────────────────────────────────────────── + // The cached object is the whole response, `asOf` included, so a hit is + // one Redis GET and no Postgres at all. Its own age is recomputed and + // republished under `cache` so it never masquerades as fresh. + const cached = await getCachedSnapshot(network) + if (cached) { + try { + const parsed = JSON.parse(cached) + const storedAt = typeof parsed?.cache?.storedAt === 'string' ? parsed.cache.storedAt : null + parsed.generatedAt = new Date().toISOString() + parsed.cache = { + ...parsed.cache, + hit: true, + ageSeconds: storedAt ? ageSeconds(Date.now(), storedAt) : null, + } + reply.header('X-Cache', 'HIT') + reply.header('Cache-Control', `public, max-age=${ttl}`) + return parsed + } catch { + // Corrupt cache entry — fall through and recompute. + } + } + + // ── One statement, one clock ───────────────────────────────────────── + // `as_of` is the database's `now()`, and it is the reference for every + // window boundary and every age below. Four queries would each take + // their own `now()` and could describe an instant that never existed. + let asOf: Date + let venueLifetime: VenueLifetimeRow[] + let series: SeriesRow[] + let latest: LatestRow[] + let pools: PoolRow[] + try { + const { rows } = await pgPool.query( + `WITH clock AS (SELECT now() AS as_of), + bounds AS (SELECT as_of, as_of - INTERVAL '24 hours' AS since FROM clock), + series AS ( + SELECT pp.source, pp.pair_key, + COUNT(*)::int AS samples_24h, + COALESCE(SUM(pp.base_volume), 0)::float AS base_volume_24h, + COALESCE(SUM(pp.counter_volume), 0)::float AS counter_volume_24h, + MAX(pp.timestamp) AS last_seen_24h + FROM price_points pp, bounds b + WHERE pp.network = $1 AND pp.timestamp > b.since AND pp.timestamp <= b.as_of + GROUP BY pp.source, pp.pair_key + ), + venue_lifetime AS ( + SELECT source, COUNT(*)::int AS samples, MAX(timestamp) AS last_seen + FROM price_points + WHERE network = $1 + GROUP BY source + ), + latest AS ( + SELECT DISTINCT ON (pair_key) pair_key, price::float AS price, timestamp AS last_seen + FROM price_points + WHERE network = $1 + ORDER BY pair_key, timestamp DESC + ), + pools AS ( + SELECT DISTINCT ON (pool_id) + pool_id, asset_a, asset_b, + reserve_a::float AS reserve_a, reserve_b::float AS reserve_b, + spot_price::float AS spot_price, fee_bp, timestamp + FROM pool_snapshots + WHERE network = $1 + ORDER BY pool_id, timestamp DESC + ), + pool_pair AS ( + SELECT DISTINCT ON (pool_id) pool_id, pair_key + FROM price_points + WHERE network = $1 AND pool_id IS NOT NULL + ORDER BY pool_id, timestamp DESC + ) + SELECT (SELECT as_of FROM clock) AS as_of, + (SELECT COALESCE(json_agg(v), '[]') FROM venue_lifetime v) AS venues, + (SELECT COALESCE(json_agg(s), '[]') FROM series s) AS series, + (SELECT COALESCE(json_agg(l), '[]') FROM latest l) AS latest, + (SELECT COALESCE(json_agg(p), '[]') FROM ( + SELECT p.*, pp.pair_key + FROM pools p + LEFT JOIN pool_pair pp ON pp.pool_id = p.pool_id + ) p) AS pools`, + [network], + ) + const row = rows[0] ?? {} + asOf = row.as_of instanceof Date ? row.as_of : new Date(String(row.as_of ?? Date.now())) + if (!Number.isFinite(asOf.getTime())) asOf = new Date() + venueLifetime = Array.isArray(row.venues) ? row.venues : [] + series = Array.isArray(row.series) ? row.series : [] + latest = Array.isArray(row.latest) ? row.latest : [] + pools = Array.isArray(row.pools) ? row.pools : [] + } catch (err) { + return reply + .status(500) + .send({ error: `Snapshot aggregation failed: ${(err as Error).message}` }) + } + + const asOfMs = asOf.getTime() + const pairIndex = new Map( + getNetworkConfig(network).pairs.map(p => [p.pairKey, p]), + ) + + // ── Aggregate per pair (24h volumes + venues, and the latest price) ── + interface PairAgg { + pairKey: string + samples24h: number + baseVolume: number + counterVolume: number + venues: Set + lastSeen24h: string | null + price: number | null + lastSeen: string | null + } + const pairs = new Map() + const pairAgg = (pairKey: string): PairAgg => { + let p = pairs.get(pairKey) + if (!p) { + p = { + pairKey, + samples24h: 0, + baseVolume: 0, + counterVolume: 0, + venues: new Set(), + lastSeen24h: null, + price: null, + lastSeen: null, + } + pairs.set(pairKey, p) + } + return p + } + for (const s of series) { + const p = pairAgg(s.pair_key) + p.samples24h += Number(s.samples_24h) || 0 + p.baseVolume += num(s.base_volume_24h) ?? 0 + p.counterVolume += num(s.counter_volume_24h) ?? 0 + p.venues.add(s.source) + const seen = iso(s.last_seen_24h) + if (seen && (!p.lastSeen24h || seen > p.lastSeen24h)) p.lastSeen24h = seen + } + for (const l of latest) { + const p = pairAgg(l.pair_key) + p.price = num(l.price) + p.lastSeen = iso(l.last_seen) + } + + // ── Venues ─────────────────────────────────────────────────────────── + const lifetimeBySource = new Map() + for (const v of venueLifetime) lifetimeBySource.set(v.source, v) + + const descriptors = venueDescriptors(network) + const knownSources = new Set(descriptors.map(d => d.source)) + for (const v of venueLifetime) { + if (!knownSources.has(v.source)) { + descriptors.push({ name: v.source, source: v.source, type: 'unknown', enabled: null }) + } + } + + const venue24h = new Map() + for (const s of series) { + const acc = venue24h.get(s.source) ?? { samples: 0, pairs: 0, lastSeen: null } + acc.samples += Number(s.samples_24h) || 0 + acc.pairs += 1 + const seen = iso(s.last_seen_24h) + if (seen && (!acc.lastSeen || seen > acc.lastSeen)) acc.lastSeen = seen + venue24h.set(s.source, acc) + } + + const venues = descriptors.map(d => { + const life = lifetimeBySource.get(d.source) + const lifeLastSeen = iso(life?.last_seen) + const samples = Number(life?.samples) || 0 + const w = venue24h.get(d.source) + const age = ageSeconds(asOfMs, lifeLastSeen) + let status: 'reporting' | 'stale' | 'silent' | 'disabled' + if (samples > 0) { + status = age !== null && age > STALE_AFTER_SECONDS ? 'stale' : 'reporting' + } else if (d.enabled === false) { + status = 'disabled' + } else { + status = 'silent' + } + return { + name: d.name, + source: d.source, + type: d.type, + enabled: d.enabled, + status, + samples, + samples24h: w?.samples ?? 0, + pairs24h: w?.pairs ?? 0, + lastUpdated: lifeLastSeen, + ageSeconds: age, + } + }) + + const venuesExpected = descriptors.filter(d => d.enabled === true).length + const venuesReporting = venues.filter(v => v.samples > 0).length + + // ── 24h volume: per venue, and a cross-pair total only when one unit ─ + const venueVolume = new Map() + for (const s of series) { + const acc = venueVolume.get(s.source) ?? { base: 0, counter: 0 } + acc.base += num(s.base_volume_24h) ?? 0 + acc.counter += num(s.counter_volume_24h) ?? 0 + venueVolume.set(s.source, acc) + } + const totalRows = [...pairs.values()].map(p => { + const resolved = resolvePair(pairIndex, p.pairKey) + return { + assetA: resolved?.assetA ?? { code: p.pairKey, issuer: null }, + assetB: resolved?.assetB ?? { code: p.pairKey, issuer: null }, + base: p.baseVolume, + counter: p.counterVolume, + } + }) + const volume24h = { + window: '24h', + sampleCount: series.reduce((s, r) => s + (Number(r.samples_24h) || 0), 0), + byVenue: [...venueVolume.entries()] + .map(([venue, v]) => ({ + venue, + baseVolume: v.base, + counterVolume: v.counter, + samples: venue24h.get(venue)?.samples ?? 0, + pairs: venue24h.get(venue)?.pairs ?? 0, + lastUpdated: venue24h.get(venue)?.lastSeen ?? null, + ageSeconds: ageSeconds(asOfMs, venue24h.get(venue)?.lastSeen ?? null), + })) + .sort((a, b) => a.venue.localeCompare(b.venue)), + // NOTE: baseVolume sums each pair's base-asset amount (asset A units) + // and counterVolume the counter side, so a per-venue sum mixes units + // when that venue trades pairs with different bases. `total` is the + // unit-correct figure and is null when no single unit fits. + total: collapseVolume(totalRows), + } + + // ── Top pairs ──────────────────────────────────────────────────────── + // Ranked by the pair's own base-asset volume (a heuristic across mixed + // units), ties broken by activity then key. Each entry names its unit. + const topPairs = [...pairs.values()] + .sort((a, b) => + b.baseVolume - a.baseVolume || + b.samples24h - a.samples24h || + a.pairKey.localeCompare(b.pairKey), + ) + .slice(0, TOP_PAIRS_LIMIT) + .map(p => { + const resolved = resolvePair(pairIndex, p.pairKey) + return { + pairKey: p.pairKey, + configured: resolved?.configured ?? false, + assetA: resolved ? assetObj(resolved.assetA) : null, + assetB: resolved ? assetObj(resolved.assetB) : null, + price: p.price, + baseVolume24h: p.baseVolume, + counterVolume24h: p.counterVolume, + samples24h: p.samples24h, + venues: [...p.venues].sort(), + lastUpdated: p.lastSeen ?? p.lastSeen24h, + ageSeconds: ageSeconds(asOfMs, p.lastSeen ?? p.lastSeen24h), + } + }) + + // ── TVL ────────────────────────────────────────────────────────────── + // No USD valuation is fabricated: reserves are reported in each pool's + // own asset units, per asset and per pool. A pool snapshot stores only + // asset codes, so the issuer is recovered from the watched-pair config + // when the pool's pair is known. + const tvlByAsset = new Map }>() + let tvlLastUpdated: string | null = null + const poolDetails = pools.map(row => { + const resolved = row.pair_key ? resolvePair(pairIndex, row.pair_key) : null + const assetA: AssetId = resolved?.assetA.code === row.asset_a + ? resolved.assetA + : resolved?.assetB.code === row.asset_a + ? resolved.assetB + : { code: row.asset_a, issuer: null } + const assetB: AssetId = resolved?.assetB.code === row.asset_b + ? resolved.assetB + : resolved?.assetA.code === row.asset_b + ? resolved.assetA + : { code: row.asset_b, issuer: null } + const reserveA = num(row.reserve_a) + const reserveB = num(row.reserve_b) + if (reserveA != null) { + const acc = tvlByAsset.get(assetKey(assetA)) ?? { asset: assetA, amount: 0, pools: new Set() } + acc.amount += reserveA + acc.pools.add(row.pool_id) + tvlByAsset.set(assetKey(assetA), acc) + } + if (reserveB != null) { + const acc = tvlByAsset.get(assetKey(assetB)) ?? { asset: assetB, amount: 0, pools: new Set() } + acc.amount += reserveB + acc.pools.add(row.pool_id) + tvlByAsset.set(assetKey(assetB), acc) + } + const ts = iso(row.timestamp) + if (ts && (!tvlLastUpdated || ts > tvlLastUpdated)) tvlLastUpdated = ts + return { + poolId: row.pool_id, + pairKey: row.pair_key, + assetA: assetObj(assetA), + assetB: assetObj(assetB), + reserveA, + reserveB, + spotPrice: num(row.spot_price), + feeBp: row.fee_bp == null ? null : Number(row.fee_bp), + lastUpdated: ts, + ageSeconds: ageSeconds(asOfMs, ts), + } + }) + const tvlAge = ageSeconds(asOfMs, tvlLastUpdated) + const tvl = { + note: 'Reserves are reported in each pool asset\'s own units — no USD valuation is inferred, because the snapshot holds no trustworthy USD price to convert at.', + poolCount: pools.length, + sampleCount: pools.length, + lastUpdated: tvlLastUpdated, + ageSeconds: tvlAge, + stale: tvlAge === null ? pools.length > 0 : tvlAge > STALE_AFTER_SECONDS, + byAsset: [...tvlByAsset.values()] + .map(a => ({ asset: assetObj(a.asset), amount: a.amount, pools: a.pools.size })) + .sort((a, b) => b.amount - a.amount), + pools: poolDetails, + } + + // ── Pegs ───────────────────────────────────────────────────────────── + // One entry per watched pair that contains a known stablecoin. The + // reading is that asset's price in the pair's other asset, ideally 1.0 + // against its fiat target. When the other side is not the target (or a + // sibling stablecoin of the same target) there is nothing to compare + // against, and the snapshot says so rather than inventing a reference. + const pegs: { + asset: { code: string; issuer: string | null } + target: string + pairKey: string + quote: { code: string; issuer: string | null } | null + price: number | null + deviationBp: number | null + status: 'on_peg' | 'off_peg' | 'no_reference' | 'no_data' + reason: string | null + samples24h: number + lastUpdated: string | null + }[] = [] + for (const pair of getNetworkConfig(network).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] + if (!target) continue + + const agg = pairs.get(pair.pairKey) + const lastUpdated = agg?.lastSeen ?? null + const samples24h = agg?.samples24h ?? 0 + const base = { + asset: assetObj(pegged), + target, + pairKey: pair.pairKey, + quote: assetObj(quote), + samples24h, + lastUpdated, + } + + if (agg?.price == null) { + pegs.push({ ...base, price: null, deviationBp: null, status: 'no_data', reason: 'no price observed for this pair' }) + continue + } + + // 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. + const reading = side === 'assetA' ? agg.price : 1 / agg.price + const quoteIsReference = + quote.code === target || PEGGED_ASSETS[quote.code] === target + + if (!quoteIsReference) { + pegs.push({ + ...base, + price: reading, + deviationBp: null, + status: 'no_reference', + reason: `quoted in ${quote.code}, which is not ${target} or a ${target} stablecoin; deviation from ${target} is not measurable here`, + }) + continue + } + + const deviationBp = (reading - 1) * 10_000 + pegs.push({ + ...base, + price: reading, + deviationBp, + status: Math.abs(deviationBp) <= PEG_TOLERANCE_BPS ? 'on_peg' : 'off_peg', + reason: null, + }) + } + } + + // ── Freshness ──────────────────────────────────────────────────────── + const candidateLastSeen = [ + ...venueLifetime.map(v => iso(v.last_seen)), + ...latest.map(l => iso(l.last_seen)), + tvlLastUpdated, + ].filter((x): x is string => x != null) + const lastUpdated = candidateLastSeen.length + ? candidateLastSeen.reduce((a, b) => (a > b ? a : b)) + : null + const freshnessAge = ageSeconds(asOfMs, lastUpdated) + const freshness = { + asOf: asOf.toISOString(), + windowStart: new Date(asOfMs - 24 * 60 * 60 * 1000).toISOString(), + windowEnd: asOf.toISOString(), + lastUpdated, + ageSeconds: freshnessAge, + staleAfterSeconds: STALE_AFTER_SECONDS, + stale: lastUpdated === null || (freshnessAge !== null && freshnessAge > STALE_AFTER_SECONDS), + venuesReporting, + venuesExpected, + venuesSilent: Math.max(0, venuesExpected - venuesReporting), + } + + const result = { + network, + asOf: asOf.toISOString(), + generatedAt: new Date().toISOString(), + window: '24h', + cache: { + ttlSeconds: ttl, + maxAgeSeconds: ttl, + hit: false, + storedAt: asOf.toISOString(), + ageSeconds: 0, + }, + venues, + tvl, + volume24h, + topPairs, + pegs, + freshness, + } + + await setCachedSnapshot(network, result, ttl) + reply.header('X-Cache', 'MISS') + reply.header('Cache-Control', `public, max-age=${ttl}`) + return result + }, + ) +}