diff --git a/.changeset/openapi-route-coverage.md b/.changeset/openapi-route-coverage.md new file mode 100644 index 00000000..3b9563b8 --- /dev/null +++ b/.changeset/openapi-route-coverage.md @@ -0,0 +1,11 @@ +--- +'lens': patch +--- + +Document the full public route surface in `openapi.yaml` (params, the shared +`?network=` query, and response schemas) and add `tests/openapi.test.ts`, which +boots the app, enumerates every registered route and fails when a non-internal +route is missing from the spec. Operator-only and non-HTTP routes are excluded +through an explicit, commented allow-list in `src/openapi/coverage.ts`. The +generator is now importable and deterministic, and a test asserts the committed +`openapi.json` matches it. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..238cc4dc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Normalise line endings in text artifacts so tooling that byte-compares them +# does not depend on a contributor's git config. In particular, +# tests/openapi.test.ts compares openapi.json byte-for-byte, which fails on +# Windows with core.autocrlf=true unless the checkout is forced to LF. +*.json text eol=lf +*.yaml text eol=lf +*.yml text eol=lf diff --git a/README.md b/README.md index 8238dbda..4c4a2e67 100644 --- a/README.md +++ b/README.md @@ -15,20 +15,42 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity | Method | Path | Description | |---|---|---| | GET | `/price/:assetA/:assetB` | Current VWAP, 24h volume, best route | -| GET | `/price/twap/:assetA/:assetB` | TWAP pricing over a time window | -| GET | `/price/vwap/:assetA/:assetB` | VWAP pricing over a time window | | GET | `/price/:assetA/:assetB/route?amount=1000` | Best execution route for a given amount | +| GET | `/price/:assetA/:assetB/depth?amount=1000` | Simulated order-book depth and execution slippage | | GET | `/price/:assetA/:assetB/history?window=1h` | OHLCV history (`1m`, `5m`, `1h`, `24h`) | +| GET | `/price/twap/:assetA/:assetB?window=60&sampleInterval=60&method=iqr` | Manipulation-resistant time-weighted average price | +| GET | `/price/vwap/:assetA/:assetB?window=60&source=SDEX&method=iqr` | Volume-weighted average price | +| GET | `/candles/:assetA/:assetB?interval=1h&from=…&to=…` | OHLCV candles (`1m`, `5m`, `15m`, `1h`, `4h`, `1d`) | | GET | `/prices/history?pair=XLM/USDC&from=…&to=…&interval=1m` | Historical 1-minute price snapshots, optionally aggregated (`1m`, `5m`, `1h`); honours `?network=`; ~30-day retention | | GET | `/pools` | Active AMM pools being watched | | GET | `/pairs` | Watched trading pairs | +| GET | `/volumes/:asset?window=24h` | Cross-venue traded volume (`24h`, `7d`, `30d`) with a per-venue breakdown | +| GET | `/spreads/:asset?window=5m` | Per-venue bid/ask spread, tightest first (`5m`, `15m`, `1h`, `24h`) | +| GET | `/compare/:asset` | Lens vs Reflector oracle price comparison | +| GET | `/screener?sortBy=volume&order=desc&limit=20` | Screen pairs by volume, 24h change, liquidity and price | +| GET | `/benchmark/:asset?target=USD` | Peg-deviation statistics versus a target | +| GET | `/basket?asset=XLM&asset=USDC&weight=0.6&weight=0.4` | Weighted price of a basket of assets | | GET | `/status` | Indexer health | | GET | `/discovery/resources?type=&payTo=&network=&extensions=&limit=&offset=` | Bazaar catalog of x402-discoverable resources (spec: [`bazaar`](https://github.com/x402-foundation/x402/blob/main/specs/extensions/bazaar.md)) | - -Every route accepts an optional `?network=testnet\|mainnet` query param (or -`x-network` header) to pick the Stellar network — default is `testnet`. An -unrecognised value gets `400`, and an omitted value falls back to this -instance's `STELLAR_NETWORK`. +| GET | `/usage/me` | Usage and quota for the calling API key (requires `Authorization: Bearer `) | +| GET | `/supported` | Payment kinds and extensions this facilitator supports | +| POST | `/verify` | Verify an x402 payment payload | +| POST | `/settle` | Settle an x402 payment (idempotent — retries replay the stored result) | +| POST | `/webhooks` | Subscribe to a price-threshold webhook | +| DELETE | `/webhooks/:id` | Delete a webhook subscription | +| GET/POST | `/graphql` | GraphQL query/mutation endpoint (GraphiQL IDE at `/graphiql`) | + +Every non-internal route above has a matching entry in [`openapi.yaml`](openapi.yaml); +`tests/openapi.test.ts` boots the server and fails if a registered route is +missing from the spec. Operator-only and non-HTTP routes (`/admin/keys*`, +`/admin/usage*`, `/metrics`, `/ws`, `/graphiql*`) are deliberately excluded via +the commented allow-list in [`src/openapi/coverage.ts`](src/openapi/coverage.ts). + +The `?network=testnet\|mainnet` query param (or `x-network` header) selects the +Stellar network. It is validated on every request — an unrecognised value gets a +`400` — and defaults to this instance's `STELLAR_NETWORK`. Only the routes whose +spec entry declares a `network` parameter answer for the selected network; the +ones listed below still read a single instance-wide network. Per-request today: `/price/:assetA/:assetB` (its VWAP, OHLCV, AMM and best-route reads), `/price/:assetA/:assetB/route`, `/price/:assetA/:assetB/depth`, diff --git a/openapi.json b/openapi.json index 412fc9a3..822fa736 100644 --- a/openapi.json +++ b/openapi.json @@ -85,6 +85,9 @@ "type": "string" }, "example": "USDC" + }, + { + "$ref": "#/components/parameters/NetworkQuery" } ], "responses": { @@ -110,157 +113,6 @@ } } }, - "/price/twap/{assetA}/{assetB}": { - "get": { - "operationId": "getTwap", - "summary": "Get TWAP pricing over a time window", - "security": [ - { - "x402": [] - } - ], - "parameters": [ - { - "name": "assetA", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assetB", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "window", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "default": 60 - } - }, - { - "name": "sampleInterval", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "default": 60 - } - }, - { - "name": "method", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "iqr", - "modified_zscore" - ], - "default": "iqr" - } - } - ], - "responses": { - "200": { - "description": "TWAP price data" - }, - "400": { - "description": "Invalid parameters" - }, - "402": { - "$ref": "#/components/responses/PaymentRequired" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - } - } - } - }, - "/price/vwap/{assetA}/{assetB}": { - "get": { - "operationId": "getVwapTimewindow", - "summary": "Get VWAP pricing over a time window", - "security": [ - { - "x402": [] - } - ], - "parameters": [ - { - "name": "assetA", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assetB", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "window", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "default": 60 - } - }, - { - "name": "source", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "SDEX", - "AMM" - ] - } - }, - { - "name": "method", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "iqr", - "modified_zscore" - ], - "default": "iqr" - } - } - ], - "responses": { - "200": { - "description": "VWAP price data" - }, - "400": { - "description": "Invalid parameters" - }, - "402": { - "$ref": "#/components/responses/PaymentRequired" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - } - } - } - }, "/price/{assetA}/{assetB}/route": { "get": { "operationId": "getBestRoute", @@ -295,6 +147,9 @@ "type": "number", "default": 1000 } + }, + { + "$ref": "#/components/parameters/NetworkQuery" } ], "responses": { @@ -408,6 +263,11 @@ "x402": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], "responses": { "200": { "description": "List of pools", @@ -591,7 +451,7 @@ }, "/prices/history": { "get": { - "operationId": "getPriceHistory", + "operationId": "getPriceSnapshots", "summary": "Get historical 1-minute price snapshots", "description": "Returns minute-resolution price snapshots for a pair over [from, to], optionally aggregated into 5m or 1h buckets. Snapshots are retained for ~30 days. Gated by x402 (matches the /price prefix).", "security": [ @@ -640,6 +500,9 @@ "type": "string", "format": "date-time" } + }, + { + "$ref": "#/components/parameters/NetworkQuery" } ], "responses": { @@ -653,6 +516,13 @@ "pair": { "type": "string" }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, "interval": { "type": "string" }, @@ -707,6 +577,11 @@ "operationId": "listPairs", "summary": "List all active trading pairs", "security": [], + "parameters": [ + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], "responses": { "200": { "description": "List of pairs", @@ -797,263 +672,2170 @@ } } } - } - }, - "components": { - "securitySchemes": { - "x402": { - "type": "apiKey", - "name": "X-PAYMENT", - "in": "header", - "description": "Base64 encoded JSON payment proof for x402 micropayments." - }, - "adminAuth": { - "type": "apiKey", - "name": "X-Admin-Key", - "in": "header" - } }, - "responses": { - "PaymentRequired": { - "description": "Payment Required", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "x402Version": { - "type": "integer" - }, - "accepts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "scheme": { - "type": "string" - }, - "price": { - "type": "string" - }, - "network": { - "type": "string" - }, - "payTo": { - "type": "string" - } - } - } - }, - "error": { - "type": "string" - }, - "description": { - "type": "string" - } - } - } + "/price/{assetA}/{assetB}/depth": { + "get": { + "operationId": "getDepth", + "summary": "Get order-book depth and execution slippage for a pair", + "description": "Simulates an order of `amount` units of assetA and returns the price ladder plus the execution price and slippage versus the spot price.", + "security": [ + { + "x402": [] } - } - }, - "TooManyRequests": { - "description": "Rate limit exceeded", - "headers": { - "Retry-After": { + ], + "parameters": [ + { + "name": "assetA", + "in": "path", + "required": true, "schema": { - "type": "integer" + "type": "string" }, - "description": "Seconds to wait before retrying" + "example": "XLM" }, - "X-RateLimit-Remaining": { + { + "name": "assetB", + "in": "path", + "required": true, "schema": { - "type": "integer" + "type": "string" }, - "description": "Remaining requests in current window" - } - }, - "content": { - "application/json": { + "example": "USDC" + }, + { + "name": "amount", + "in": "query", + "required": false, "schema": { - "type": "object", - "properties": { - "statusCode": { - "type": "integer" - }, - "error": { - "type": "string" - }, - "message": { - "type": "string" - }, - "retryAfter": { - "type": "string" + "type": "number", + "default": 1000 + } + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Depth ladder", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DepthResponse" + } + } + } + }, + "400": { + "description": "Invalid amount" + }, + "402": { + "$ref": "#/components/responses/PaymentRequired" + }, + "404": { + "description": "Pair not watched" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/price/twap/{assetA}/{assetB}": { + "get": { + "operationId": "getTwap", + "summary": "Get a manipulation-resistant time-weighted average price", + "security": [ + { + "x402": [] + } + ], + "parameters": [ + { + "name": "assetA", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assetB", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "window", + "in": "query", + "required": false, + "description": "Lookback window in minutes.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1440, + "default": 60 + } + }, + { + "name": "sampleInterval", + "in": "query", + "required": false, + "description": "Sampling grid width in seconds.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 3600, + "default": 60 + } + }, + { + "name": "method", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "iqr", + "modified_zscore" + ], + "default": "iqr" + } + } + ], + "responses": { + "200": { + "description": "TWAP result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwapResponse" + } + } + } + }, + "400": { + "description": "Invalid parameters" + }, + "402": { + "$ref": "#/components/responses/PaymentRequired" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "description": "TWAP computation failed" + } + } + } + }, + "/price/vwap/{assetA}/{assetB}": { + "get": { + "operationId": "getVwap", + "summary": "Get a volume-weighted average price", + "security": [ + { + "x402": [] + } + ], + "parameters": [ + { + "name": "assetA", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assetB", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "window", + "in": "query", + "required": false, + "description": "Lookback window in minutes.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1440, + "default": 60 + } + }, + { + "name": "source", + "in": "query", + "required": false, + "description": "Restrict to a single venue (omitted pools every venue).", + "schema": { + "type": "string", + "enum": [ + "SDEX", + "AMM" + ] + } + }, + { + "name": "method", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "iqr", + "modified_zscore" + ], + "default": "iqr" + } + } + ], + "responses": { + "200": { + "description": "VWAP result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VwapResponse" + } + } + } + }, + "400": { + "description": "Invalid parameters" + }, + "402": { + "$ref": "#/components/responses/PaymentRequired" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "description": "VWAP computation failed" + } + } + } + }, + "/volumes/{asset}": { + "get": { + "operationId": "getVolumes", + "summary": "Get cross-venue traded volume for an asset", + "description": "Sums traded volume for `asset` across every pair and venue it appears in, measured in the asset's own units, with a per-venue breakdown.", + "security": [], + "parameters": [ + { + "name": "asset", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "XLM" + }, + { + "name": "window", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "24h", + "7d", + "30d" + ], + "default": "24h" + } + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Aggregated volume", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VolumeResponse" } } } + }, + "400": { + "description": "Invalid window" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "description": "Volume aggregation failed" + } + } + } + }, + "/spreads/{asset}": { + "get": { + "operationId": "getSpreads", + "summary": "Get the tightest per-venue bid/ask spread for an asset", + "description": "Reports, for every (venue, pair) that quoted `asset` inside the window, the observed low, high and spread in basis points, tightest first. The spread is the dispersion of recent prints, not a resting order book's top-of-book gap.", + "security": [], + "parameters": [ + { + "name": "asset", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "XLM" + }, + { + "name": "window", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "5m", + "15m", + "1h", + "24h" + ], + "default": "5m" + } + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Per-venue spreads", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SpreadResponse" + } + } + } + }, + "400": { + "description": "Invalid window or network" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "description": "Spread aggregation failed" + } + } + } + }, + "/compare/{asset}": { + "get": { + "operationId": "compareAsset", + "summary": "Compare Lens and Reflector oracle prices for an asset", + "security": [], + "parameters": [ + { + "name": "asset", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "XLM" + } + ], + "responses": { + "200": { + "description": "Price comparison", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompareResponse" + } + } + } + }, + "404": { + "description": "No price data found for the asset" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/screener": { + "get": { + "operationId": "screenPairs", + "summary": "Screen pairs by volume, change, liquidity and price", + "security": [], + "parameters": [ + { + "name": "volume", + "in": "query", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "change_24h_min", + "in": "query", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "change_24h_max", + "in": "query", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "price_min", + "in": "query", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "price_max", + "in": "query", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "liquidity", + "in": "query", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "volume", + "change_24h", + "price", + "liquidity" + ], + "default": "volume" + } + }, + { + "name": "order", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor returned as `nextCursor` by the previous page.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Screened pairs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScreenerResponse" + } + } + } + }, + "400": { + "description": "Invalid filter, sort or cursor" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/benchmark/{asset}": { + "get": { + "operationId": "getBenchmark", + "summary": "Get peg-deviation statistics for an asset against a target", + "security": [], + "parameters": [ + { + "name": "asset", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "USDC" + }, + { + "name": "target", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "USD" + } + } + ], + "responses": { + "200": { + "description": "Benchmark statistics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BenchmarkResponse" + } + } + } + }, + "404": { + "description": "Pair not watched" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "description": "Benchmark computation failed" + } + } + } + }, + "/basket": { + "get": { + "operationId": "getBasket", + "summary": "Compute the weighted price of a basket of assets", + "security": [], + "parameters": [ + { + "name": "asset", + "in": "query", + "required": true, + "description": "Asset code; repeat for each basket member (at least two).", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "example": [ + "XLM", + "USDC" + ] + }, + { + "name": "weight", + "in": "query", + "required": true, + "description": "Relative weight for each `asset`, in the same order.", + "schema": { + "type": "array", + "items": { + "type": "number" + } + }, + "style": "form", + "explode": true, + "example": [ + 0.6, + 0.4 + ] + } + ], + "responses": { + "200": { + "description": "Basket price", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasketResponse" + } + } + } + }, + "400": { + "description": "Missing assets or mismatched/invalid weights" + }, + "404": { + "description": "No price data for one or more assets" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/discovery/resources": { + "get": { + "operationId": "listDiscoveryResources", + "summary": "Browse the x402 Bazaar catalog of discoverable resources", + "security": [], + "parameters": [ + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "http", + "mcp" + ] + } + }, + { + "name": "payTo", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "network", + "in": "query", + "required": false, + "description": "Catalog filter matching the resource's payment network. Accepts either the Lens network name (`mainnet`/`testnet`) or the CAIP-2 id (`stellar:pubnet`/`stellar:testnet`).", + "schema": { + "type": "string" + } + }, + { + "name": "extensions", + "in": "query", + "required": false, + "description": "Filter to resources declaring this extension (e.g. `bazaar`).", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "Paginated catalog page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscoveryResponse" + } + } + } + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/webhooks": { + "post": { + "operationId": "createWebhook", + "summary": "Subscribe to a price-threshold webhook", + "description": "Registers an HTTPS endpoint to be called when the pair's price crosses `threshold` in the given `direction`. The response contains the signing secret exactly once.", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "url", + "assetA", + "assetB", + "threshold", + "direction" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "HTTPS endpoint to deliver events to." + }, + "assetA": { + "type": "string", + "example": "XLM" + }, + "assetB": { + "type": "string", + "example": "USDC" + }, + "threshold": { + "type": "number" + }, + "direction": { + "type": "string", + "enum": [ + "above", + "below" + ] + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Webhook created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookCreatedResponse" + } + } + } + }, + "400": { + "description": "Invalid webhook definition" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/webhooks/{id}": { + "delete": { + "operationId": "deleteWebhook", + "summary": "Delete a webhook subscription", + "security": [], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Webhook deleted (idempotent — a missing id also returns 204)" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/usage/me": { + "get": { + "operationId": "getMyUsage", + "summary": "Get usage and quota for the calling API key", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Usage summary for the authenticated key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid API key" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, + "/supported": { + "get": { + "operationId": "getSupported", + "summary": "List the payment kinds and extensions this facilitator supports", + "security": [], + "responses": { + "200": { + "description": "Supported payment kinds and extensions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SupportedResponse" + } + } + } + } + } + } + }, + "/verify": { + "post": { + "operationId": "verifyPayment", + "summary": "Verify an x402 payment payload", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Verification result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyResponse" + } + } + } + }, + "400": { + "description": "Malformed request or invalid payment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyResponse" + } + } + } + }, + "500": { + "description": "Internal verification error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyResponse" + } + } + } + } + } + } + }, + "/settle": { + "post": { + "operationId": "settlePayment", + "summary": "Settle an x402 payment (idempotent)", + "description": "Submits the payment transaction and records the attempt keyed on its transaction hash. Retrying the same payload replays the stored answer instead of paying twice.", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Settlement result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettleResponse" + } + } + } + }, + "400": { + "description": "Malformed payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettleResponse" + } + } + } + } + } + } + }, + "/graphql": { + "get": { + "operationId": "graphqlGet", + "summary": "Execute a GraphQL query over HTTP GET", + "security": [], + "parameters": [ + { + "name": "query", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "variables", + "in": "query", + "required": false, + "description": "JSON-encoded variables object.", + "schema": { + "type": "string" + } + }, + { + "name": "operationName", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "GraphQL result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GraphQlResponse" + } + } + } + } + } + }, + "post": { + "operationId": "graphqlPost", + "summary": "Execute a GraphQL query, mutation or subscription handshake", + "security": [ + { + "x402": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "query" + ], + "properties": { + "query": { + "type": "string" + }, + "variables": { + "type": "object" + }, + "operationName": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "GraphQL result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GraphQlResponse" + } + } + } + }, + "402": { + "$ref": "#/components/responses/PaymentRequired" + } + } + } + } + }, + "components": { + "securitySchemes": { + "x402": { + "type": "apiKey", + "name": "X-PAYMENT", + "in": "header", + "description": "Base64 encoded JSON payment proof for x402 micropayments." + }, + "adminAuth": { + "type": "apiKey", + "name": "X-Admin-Key", + "in": "header" + }, + "apiKeyAuth": { + "type": "http", + "scheme": "bearer", + "description": "Client API key issued through the admin API, sent as `Authorization: Bearer `." + } + }, + "parameters": { + "NetworkQuery": { + "name": "network", + "in": "query", + "required": false, + "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.", + "schema": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ], + "default": "testnet" + } + } + }, + "responses": { + "PaymentRequired": { + "description": "Payment Required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "x402Version": { + "type": "integer" + }, + "accepts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "scheme": { + "type": "string" + }, + "price": { + "type": "string" + }, + "network": { + "type": "string" + }, + "payTo": { + "type": "string" + } + } + } + }, + "error": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + } + }, + "TooManyRequests": { + "description": "Rate limit exceeded", + "headers": { + "Retry-After": { + "schema": { + "type": "integer" + }, + "description": "Seconds to wait before retrying" + }, + "X-RateLimit-Remaining": { + "schema": { + "type": "integer" + }, + "description": "Remaining requests in current window" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "statusCode": { + "type": "integer" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "retryAfter": { + "type": "string" + } + } + } + } + } + } + }, + "schemas": { + "Asset": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "issuer": { + "type": "string", + "nullable": true + } + } + }, + "AggregatedPrice": { + "type": "object", + "properties": { + "assetA": { + "type": "string" + }, + "assetB": { + "type": "string" + }, + "pairKey": { + "type": "string" + }, + "price": { + "type": "number" + }, + "sdexPrice": { + "type": "number" + }, + "ammPrice": { + "type": "number" + }, + "bestRoute": { + "type": "string", + "enum": [ + "SDEX", + "AMM", + "SPLIT", + "UNKNOWN" + ] + }, + "volume24h": { + "type": "number" + }, + "sdexVolume24h": { + "type": "number" + }, + "ammVolume24h": { + "type": "number" + }, + "vwap1m": { + "type": "number" + }, + "vwap5m": { + "type": "number" + }, + "vwap1h": { + "type": "number" + }, + "vwap24h": { + "type": "number" + }, + "priceChange24h": { + "type": "number" + }, + "lastUpdated": { + "type": "string", + "format": "date-time" + }, + "sources": { + "type": "integer" + }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "unknown" + ] + }, + "lastTradeAgeSeconds": { + "type": "integer", + "nullable": true + } + } + }, + "RouteInfo": { + "type": "object", + "properties": { + "route": { + "type": "string", + "enum": [ + "SDEX", + "AMM", + "SPLIT", + "UNKNOWN" + ] + }, + "sdexPrice": { + "type": "number" + }, + "ammPrice": { + "type": "number" + }, + "estimatedOutput": { + "type": "number" + }, + "slippagePct": { + "type": "number" + }, + "recommendation": { + "type": "string" + } + } + }, + "PriceBucket": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "format": "date-time" + }, + "window": { + "type": "string" + }, + "vwap": { + "type": "number" + }, + "sdexVwap": { + "type": "number", + "nullable": true + }, + "ammVwap": { + "type": "number", + "nullable": true + }, + "volume": { + "type": "number" + }, + "tradeCount": { + "type": "integer" + }, + "open": { + "type": "number", + "nullable": true + }, + "close": { + "type": "number", + "nullable": true + }, + "high": { + "type": "number", + "nullable": true + }, + "low": { + "type": "number", + "nullable": true + } + } + }, + "PriceLevel": { + "type": "object", + "required": [ + "price", + "size" + ], + "additionalProperties": false, + "properties": { + "price": { + "type": "number" + }, + "size": { + "type": "number" + } + } + }, + "DepthResponse": { + "type": "object", + "required": [ + "assetA", + "assetB", + "pairKey", + "spotPrice", + "executionPrice", + "slippagePct", + "asks", + "bids", + "source" + ], + "additionalProperties": false, + "properties": { + "assetA": { + "type": "string" + }, + "assetB": { + "type": "string" + }, + "pairKey": { + "type": "string" + }, + "spotPrice": { + "type": "number" + }, + "executionPrice": { + "type": "number" + }, + "slippagePct": { + "type": "number" + }, + "asks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PriceLevel" + } + }, + "bids": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PriceLevel" + } + }, + "source": { + "type": "string", + "enum": [ + "AMM", + "SDEX", + "BOTH" + ] + } + } + }, + "TwapResponse": { + "type": "object", + "required": [ + "assetA", + "assetB", + "pairKey", + "twap", + "windowMinutes", + "sampleIntervalSeconds", + "startTime", + "endTime", + "sampleCount", + "outlierRejected", + "filterMethod" + ], + "additionalProperties": false, + "properties": { + "assetA": { + "type": "string" + }, + "assetB": { + "type": "string" + }, + "pairKey": { + "type": "string" + }, + "twap": { + "type": "number" + }, + "windowMinutes": { + "type": "integer" + }, + "sampleIntervalSeconds": { + "type": "integer" + }, + "startTime": { + "type": "string", + "format": "date-time" + }, + "endTime": { + "type": "string", + "format": "date-time" + }, + "sampleCount": { + "type": "integer" + }, + "outlierRejected": { + "type": "integer" + }, + "filterMethod": { + "type": "string", + "enum": [ + "iqr", + "modified_zscore" + ] + } + } + }, + "VwapResponse": { + "type": "object", + "required": [ + "assetA", + "assetB", + "pairKey", + "vwap", + "windowMinutes", + "source", + "startTime", + "endTime", + "sampleCount", + "volumeTotal", + "outlierRejected", + "filterMethod" + ], + "additionalProperties": false, + "properties": { + "assetA": { + "type": "string" + }, + "assetB": { + "type": "string" + }, + "pairKey": { + "type": "string" + }, + "vwap": { + "type": "number" + }, + "windowMinutes": { + "type": "integer" + }, + "source": { + "type": "string" + }, + "startTime": { + "type": "string", + "format": "date-time" + }, + "endTime": { + "type": "string", + "format": "date-time" + }, + "sampleCount": { + "type": "integer" + }, + "volumeTotal": { + "type": "number" + }, + "outlierRejected": { + "type": "integer" + }, + "filterMethod": { + "type": "string", + "enum": [ + "iqr", + "modified_zscore" + ] + } + } + }, + "VolumeResponse": { + "type": "object", + "required": [ + "asset", + "window", + "startTime", + "endTime", + "totalVolume", + "byVenue", + "venues", + "tradeCount" + ], + "additionalProperties": false, + "properties": { + "asset": { + "type": "string" + }, + "window": { + "type": "string", + "enum": [ + "24h", + "7d", + "30d" + ] + }, + "startTime": { + "type": "string", + "format": "date-time" + }, + "endTime": { + "type": "string", + "format": "date-time" + }, + "totalVolume": { + "type": "number" + }, + "byVenue": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "venues": { + "type": "array", + "items": { + "type": "string" + } + }, + "tradeCount": { + "type": "integer" + } + } + }, + "SpreadVenue": { + "type": "object", + "required": [ + "venue", + "pairKey", + "bid", + "ask", + "mid", + "spreadBps", + "observations", + "lastSeen" + ], + "additionalProperties": false, + "properties": { + "venue": { + "type": "string" + }, + "pairKey": { + "type": "string" + }, + "bid": { + "type": "number" + }, + "ask": { + "type": "number" + }, + "mid": { + "type": "number" + }, + "spreadBps": { + "type": "number" + }, + "observations": { + "type": "integer" + }, + "lastSeen": { + "type": "string", + "format": "date-time" + } + } + }, + "SpreadResponse": { + "type": "object", + "required": [ + "asset", + "network", + "window", + "startTime", + "endTime", + "venues", + "tightest" + ], + "additionalProperties": false, + "properties": { + "asset": { + "type": "string" + }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, + "window": { + "type": "string", + "enum": [ + "5m", + "15m", + "1h", + "24h" + ] + }, + "startTime": { + "type": "string", + "format": "date-time" + }, + "endTime": { + "type": "string", + "format": "date-time" + }, + "venues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SpreadVenue" + } + }, + "tightest": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/SpreadVenue" + } + ] + } + } + }, + "CompareResponse": { + "type": "object", + "required": [ + "asset", + "lens", + "reflector", + "deviationPct", + "status", + "fetchedAt" + ], + "additionalProperties": false, + "properties": { + "asset": { + "type": "string" + }, + "lens": { + "type": "number", + "nullable": true + }, + "reflector": { + "type": "number", + "nullable": true + }, + "deviationPct": { + "type": "number", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "aligned", + "minor_deviation", + "major_deviation", + "partial" + ] + }, + "fetchedAt": { + "type": "string", + "format": "date-time" } } - } - }, - "schemas": { - "Asset": { + }, + "ScreenerRow": { "type": "object", + "additionalProperties": true, "properties": { - "code": { + "pair_key": { "type": "string" }, - "issuer": { + "volume": { + "type": "number" + }, + "price": { + "type": "number" + }, + "change_24h": { + "type": "number" + }, + "liquidity": { + "type": "number" + } + } + }, + "ScreenerResponse": { + "type": "object", + "required": [ + "data", + "nextCursor", + "hasMore", + "total" + ], + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScreenerRow" + } + }, + "nextCursor": { "type": "string", "nullable": true + }, + "hasMore": { + "type": "boolean" + }, + "total": { + "type": "integer", + "nullable": true } } }, - "AggregatedPrice": { + "BenchmarkResponse": { "type": "object", + "required": [ + "asset", + "target", + "pairKey", + "currentPrice", + "currentDeviationBp", + "rolling24h" + ], + "additionalProperties": false, "properties": { - "assetA": { + "asset": { "type": "string" }, - "assetB": { + "target": { "type": "string" }, "pairKey": { "type": "string" }, - "price": { - "type": "number" + "currentPrice": { + "type": "number", + "nullable": true }, - "sdexPrice": { + "currentDeviationBp": { + "type": "number", + "nullable": true + }, + "rolling24h": { + "type": "object", + "required": [ + "maxDeviationBp", + "minDeviationBp", + "maxAbsoluteDeviationBp", + "averageDeviationBp", + "sampleCount" + ], + "additionalProperties": false, + "properties": { + "maxDeviationBp": { + "type": "number", + "nullable": true + }, + "minDeviationBp": { + "type": "number", + "nullable": true + }, + "maxAbsoluteDeviationBp": { + "type": "number", + "nullable": true + }, + "averageDeviationBp": { + "type": "number", + "nullable": true + }, + "sampleCount": { + "type": "integer" + } + } + } + } + }, + "BasketResponse": { + "type": "object", + "required": [ + "basketPrice", + "components", + "weightSum", + "computedAt" + ], + "additionalProperties": false, + "properties": { + "basketPrice": { "type": "number" }, - "ammPrice": { + "components": { + "type": "array", + "items": { + "type": "object", + "required": [ + "asset", + "price", + "weight", + "contribution" + ], + "additionalProperties": false, + "properties": { + "asset": { + "type": "string" + }, + "price": { + "type": "number" + }, + "weight": { + "type": "number" + }, + "contribution": { + "type": "number" + } + } + } + }, + "weightSum": { "type": "number" }, - "bestRoute": { + "computedAt": { "type": "string", - "enum": [ - "SDEX", - "AMM", - "SPLIT", - "UNKNOWN" - ] + "format": "date-time" + } + } + }, + "DiscoveryResource": { + "type": "object", + "properties": { + "resource": { + "type": "object", + "additionalProperties": true, + "properties": { + "url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "serviceName": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "iconUrl": { + "type": "string" + } + } }, - "volume24h": { - "type": "number" + "accepts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "scheme": { + "type": "string" + }, + "price": { + "type": "string" + }, + "network": { + "type": "string" + }, + "payTo": { + "type": "string" + } + } + } }, - "sdexVolume24h": { - "type": "number" + "extensions": { + "type": "object", + "additionalProperties": true + } + } + }, + "DiscoveryResponse": { + "type": "object", + "required": [ + "resources", + "limit", + "offset", + "total" + ], + "additionalProperties": false, + "properties": { + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DiscoveryResource" + } }, - "ammVolume24h": { - "type": "number" + "limit": { + "type": "integer" }, - "vwap1m": { - "type": "number" + "offset": { + "type": "integer" }, - "vwap5m": { - "type": "number" + "total": { + "type": "integer" + } + } + }, + "WebhookCreatedResponse": { + "type": "object", + "required": [ + "id", + "secret" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string" }, - "vwap1h": { - "type": "number" + "secret": { + "type": "string" + } + } + }, + "UsageResponse": { + "type": "object", + "required": [ + "keyId", + "dailyCalls", + "dailyCents", + "monthlyCalls", + "monthlyCents", + "dailyQuotaCents", + "monthlyQuotaCents", + "dailyRemainingCents", + "monthlyRemainingCents", + "overagePolicy", + "quotaExceeded" + ], + "additionalProperties": false, + "properties": { + "keyId": { + "type": "string" }, - "vwap24h": { - "type": "number" + "dailyCalls": { + "type": "integer" }, - "priceChange24h": { - "type": "number" + "dailyCents": { + "type": "integer" }, - "lastUpdated": { - "type": "string", - "format": "date-time" + "monthlyCalls": { + "type": "integer" }, - "sources": { + "monthlyCents": { "type": "integer" }, - "confidence": { + "dailyQuotaCents": { + "type": "integer" + }, + "monthlyQuotaCents": { + "type": "integer" + }, + "dailyRemainingCents": { + "type": "integer" + }, + "monthlyRemainingCents": { + "type": "integer" + }, + "overagePolicy": { "type": "string", "enum": [ - "high", - "medium", - "low", - "unknown" + "block", + "charge_402", + "allow_overage" ] }, - "lastTradeAgeSeconds": { - "type": "integer", - "nullable": true + "quotaExceeded": { + "type": "boolean" } } }, - "RouteInfo": { + "VerifyRequest": { "type": "object", + "required": [ + "paymentPayload", + "paymentRequirements" + ], "properties": { - "route": { - "type": "string", - "enum": [ - "SDEX", - "AMM", - "SPLIT", - "UNKNOWN" - ] - }, - "sdexPrice": { - "type": "number" - }, - "ammPrice": { - "type": "number" - }, - "estimatedOutput": { - "type": "number" + "x402Version": { + "type": "integer" }, - "slippagePct": { - "type": "number" + "paymentPayload": { + "type": "object" }, - "recommendation": { - "type": "string" + "paymentRequirements": { + "type": "object" } } }, - "PriceBucket": { + "VerifyResponse": { "type": "object", + "required": [ + "isValid" + ], + "additionalProperties": true, "properties": { - "bucket": { - "type": "string", - "format": "date-time" + "isValid": { + "type": "boolean" }, - "window": { - "type": "string" - }, - "vwap": { - "type": "number" + "invalidReason": { + "type": "string", + "nullable": true }, - "sdexVwap": { - "type": "number", + "invalidMessage": { + "type": "string", "nullable": true }, - "ammVwap": { - "type": "number", + "payer": { + "type": "string", "nullable": true + } + } + }, + "SettleRequest": { + "type": "object", + "required": [ + "paymentPayload", + "paymentRequirements" + ], + "properties": { + "x402Version": { + "type": "integer" }, - "volume": { - "type": "number" + "paymentPayload": { + "type": "object" }, - "tradeCount": { - "type": "integer" + "paymentRequirements": { + "type": "object" + } + } + }, + "SettleResponse": { + "type": "object", + "required": [ + "success", + "transaction", + "network" + ], + "additionalProperties": true, + "properties": { + "success": { + "type": "boolean" }, - "open": { - "type": "number", + "errorReason": { + "type": "string", "nullable": true }, - "close": { - "type": "number", + "errorMessage": { + "type": "string", "nullable": true }, - "high": { - "type": "number", - "nullable": true + "transaction": { + "type": "string" }, - "low": { - "type": "number", + "network": { + "type": "string" + } + } + }, + "SupportedResponse": { + "type": "object", + "additionalProperties": true, + "properties": { + "kinds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "scheme": { + "type": "string" + }, + "network": { + "type": "string" + } + } + } + }, + "extensions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "GraphQlResponse": { + "type": "object", + "additionalProperties": true, + "properties": { + "data": { + "type": "object", "nullable": true + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } } } } diff --git a/openapi.yaml b/openapi.yaml index 4e0446ed..22292cac 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -60,6 +60,7 @@ paths: schema: type: string example: USDC + - $ref: '#/components/parameters/NetworkQuery' responses: '200': description: Aggregated price data @@ -74,98 +75,6 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /price/twap/{assetA}/{assetB}: - get: - operationId: getTwap - summary: Get TWAP pricing over a time window - security: - - x402: [] - parameters: - - name: assetA - in: path - required: true - schema: - type: string - - name: assetB - in: path - required: true - schema: - type: string - - name: window - in: query - required: false - schema: - type: integer - default: 60 - - name: sampleInterval - in: query - required: false - schema: - type: integer - default: 60 - - name: method - in: query - required: false - schema: - type: string - enum: [iqr, modified_zscore] - default: iqr - responses: - '200': - description: TWAP price data - '400': - description: Invalid parameters - '402': - $ref: '#/components/responses/PaymentRequired' - '429': - $ref: '#/components/responses/TooManyRequests' - - /price/vwap/{assetA}/{assetB}: - get: - operationId: getVwapTimewindow - summary: Get VWAP pricing over a time window - security: - - x402: [] - parameters: - - name: assetA - in: path - required: true - schema: - type: string - - name: assetB - in: path - required: true - schema: - type: string - - name: window - in: query - required: false - schema: - type: integer - default: 60 - - name: source - in: query - required: false - schema: - type: string - enum: [SDEX, AMM] - - name: method - in: query - required: false - schema: - type: string - enum: [iqr, modified_zscore] - default: iqr - responses: - '200': - description: VWAP price data - '400': - description: Invalid parameters - '402': - $ref: '#/components/responses/PaymentRequired' - '429': - $ref: '#/components/responses/TooManyRequests' - /price/{assetA}/{assetB}/route: get: operationId: getBestRoute @@ -189,6 +98,7 @@ paths: schema: type: number default: 1000 + - $ref: '#/components/parameters/NetworkQuery' responses: '200': description: Best route information @@ -258,6 +168,8 @@ paths: summary: Get AMM liquidity pool reserves and spot prices security: - x402: [] + parameters: + - $ref: '#/components/parameters/NetworkQuery' responses: '200': description: List of pools @@ -372,7 +284,7 @@ paths: /prices/history: get: - operationId: getPriceHistory + operationId: getPriceSnapshots summary: Get historical 1-minute price snapshots description: >- Returns minute-resolution price snapshots for a pair over [from, to], @@ -406,6 +318,7 @@ paths: schema: type: string format: date-time + - $ref: '#/components/parameters/NetworkQuery' responses: '200': description: Historical price snapshots @@ -416,6 +329,9 @@ paths: properties: pair: type: string + network: + type: string + enum: [testnet, mainnet] interval: type: string from: @@ -450,6 +366,8 @@ paths: operationId: listPairs summary: List all active trading pairs security: [] + parameters: + - $ref: '#/components/parameters/NetworkQuery' responses: '200': description: List of pairs @@ -508,104 +426,804 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' -components: - securitySchemes: - x402: - type: apiKey - name: X-PAYMENT - in: header - description: Base64 encoded JSON payment proof for x402 micropayments. - adminAuth: - type: apiKey - name: X-Admin-Key - in: header + /price/{assetA}/{assetB}/depth: + get: + operationId: getDepth + summary: Get order-book depth and execution slippage for a pair + description: >- + Simulates an order of `amount` units of assetA and returns the price + ladder plus the execution price and slippage versus the spot price. + security: + - x402: [] + parameters: + - name: assetA + in: path + required: true + schema: + type: string + example: XLM + - name: assetB + in: path + required: true + schema: + type: string + example: USDC + - name: amount + in: query + required: false + schema: + type: number + default: 1000 + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Depth ladder + content: + application/json: + schema: + $ref: '#/components/schemas/DepthResponse' + '400': + description: Invalid amount + '402': + $ref: '#/components/responses/PaymentRequired' + '404': + description: Pair not watched + '429': + $ref: '#/components/responses/TooManyRequests' - responses: - PaymentRequired: - description: Payment Required - content: - application/json: + /price/twap/{assetA}/{assetB}: + get: + operationId: getTwap + summary: Get a manipulation-resistant time-weighted average price + security: + - x402: [] + parameters: + - name: assetA + in: path + required: true schema: - type: object - properties: - x402Version: - type: integer - accepts: - type: array - items: - type: object - properties: - scheme: - type: string - price: - type: string - network: - type: string - payTo: - type: string - error: - type: string - description: - type: string - TooManyRequests: - description: Rate limit exceeded - headers: - Retry-After: + type: string + - name: assetB + in: path + required: true + schema: + type: string + - name: window + in: query + required: false + description: Lookback window in minutes. schema: type: integer - description: Seconds to wait before retrying - X-RateLimit-Remaining: + minimum: 1 + maximum: 1440 + default: 60 + - name: sampleInterval + in: query + required: false + description: Sampling grid width in seconds. schema: type: integer - description: Remaining requests in current window - content: - application/json: + minimum: 1 + maximum: 3600 + default: 60 + - name: method + in: query + required: false schema: - type: object - properties: - statusCode: - type: integer - error: - type: string - message: - type: string - retryAfter: - type: string - - schemas: - Asset: - type: object - properties: - code: - type: string - issuer: - type: string - nullable: true + type: string + enum: [iqr, modified_zscore] + default: iqr + responses: + '200': + description: TWAP result + content: + application/json: + schema: + $ref: '#/components/schemas/TwapResponse' + '400': + description: Invalid parameters + '402': + $ref: '#/components/responses/PaymentRequired' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + description: TWAP computation failed - AggregatedPrice: - type: object - properties: - assetA: - type: string - assetB: - type: string - pairKey: - type: string - price: - type: number - sdexPrice: - type: number - ammPrice: - type: number - bestRoute: - type: string - enum: [SDEX, AMM, SPLIT, UNKNOWN] - volume24h: - type: number - sdexVolume24h: - type: number - ammVolume24h: - type: number + /price/vwap/{assetA}/{assetB}: + get: + operationId: getVwap + summary: Get a volume-weighted average price + security: + - x402: [] + parameters: + - name: assetA + in: path + required: true + schema: + type: string + - name: assetB + in: path + required: true + schema: + type: string + - name: window + in: query + required: false + description: Lookback window in minutes. + schema: + type: integer + minimum: 1 + maximum: 1440 + default: 60 + - name: source + in: query + required: false + description: Restrict to a single venue (omitted pools every venue). + schema: + type: string + enum: [SDEX, AMM] + - name: method + in: query + required: false + schema: + type: string + enum: [iqr, modified_zscore] + default: iqr + responses: + '200': + description: VWAP result + content: + application/json: + schema: + $ref: '#/components/schemas/VwapResponse' + '400': + description: Invalid parameters + '402': + $ref: '#/components/responses/PaymentRequired' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + description: VWAP computation failed + + /volumes/{asset}: + get: + operationId: getVolumes + summary: Get cross-venue traded volume for an asset + description: >- + Sums traded volume for `asset` across every pair and venue it appears + in, measured in the asset's own units, with a per-venue breakdown. + security: [] + parameters: + - name: asset + in: path + required: true + schema: + type: string + example: XLM + - name: window + in: query + required: false + schema: + type: string + enum: [24h, 7d, 30d] + default: 24h + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Aggregated volume + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeResponse' + '400': + description: Invalid window + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + description: Volume aggregation failed + + /spreads/{asset}: + get: + operationId: getSpreads + summary: Get the tightest per-venue bid/ask spread for an asset + description: >- + Reports, for every (venue, pair) that quoted `asset` inside the window, + the observed low, high and spread in basis points, tightest first. + The spread is the dispersion of recent prints, not a resting order + book's top-of-book gap. + security: [] + parameters: + - name: asset + in: path + required: true + schema: + type: string + example: XLM + - name: window + in: query + required: false + schema: + type: string + enum: [5m, 15m, 1h, 24h] + default: 5m + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Per-venue spreads + content: + application/json: + schema: + $ref: '#/components/schemas/SpreadResponse' + '400': + description: Invalid window or network + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + description: Spread aggregation failed + + /compare/{asset}: + get: + operationId: compareAsset + summary: Compare Lens and Reflector oracle prices for an asset + security: [] + parameters: + - name: asset + in: path + required: true + schema: + type: string + example: XLM + responses: + '200': + description: Price comparison + content: + application/json: + schema: + $ref: '#/components/schemas/CompareResponse' + '404': + description: No price data found for the asset + '429': + $ref: '#/components/responses/TooManyRequests' + + /screener: + get: + operationId: screenPairs + summary: Screen pairs by volume, change, liquidity and price + security: [] + parameters: + - name: volume + in: query + required: false + schema: + type: number + - name: change_24h_min + in: query + required: false + schema: + type: number + - name: change_24h_max + in: query + required: false + schema: + type: number + - name: price_min + in: query + required: false + schema: + type: number + - name: price_max + in: query + required: false + schema: + type: number + - name: liquidity + in: query + required: false + schema: + type: number + - name: sortBy + in: query + required: false + schema: + type: string + enum: [volume, change_24h, price, liquidity] + default: volume + - name: order + in: query + required: false + schema: + type: string + enum: [asc, desc] + default: desc + - name: cursor + in: query + required: false + description: Opaque cursor returned as `nextCursor` by the previous page. + schema: + type: string + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Screened pairs + content: + application/json: + schema: + $ref: '#/components/schemas/ScreenerResponse' + '400': + description: Invalid filter, sort or cursor + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + description: Internal server error + + /benchmark/{asset}: + get: + operationId: getBenchmark + summary: Get peg-deviation statistics for an asset against a target + security: [] + parameters: + - name: asset + in: path + required: true + schema: + type: string + example: USDC + - name: target + in: query + required: false + schema: + type: string + default: USD + responses: + '200': + description: Benchmark statistics + content: + application/json: + schema: + $ref: '#/components/schemas/BenchmarkResponse' + '404': + description: Pair not watched + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + description: Benchmark computation failed + + /basket: + get: + operationId: getBasket + summary: Compute the weighted price of a basket of assets + security: [] + parameters: + - name: asset + in: query + required: true + description: Asset code; repeat for each basket member (at least two). + schema: + type: array + items: + type: string + style: form + explode: true + example: [XLM, USDC] + - name: weight + in: query + required: true + description: Relative weight for each `asset`, in the same order. + schema: + type: array + items: + type: number + style: form + explode: true + example: [0.6, 0.4] + responses: + '200': + description: Basket price + content: + application/json: + schema: + $ref: '#/components/schemas/BasketResponse' + '400': + description: Missing assets or mismatched/invalid weights + '404': + description: No price data for one or more assets + '429': + $ref: '#/components/responses/TooManyRequests' + + /discovery/resources: + get: + operationId: listDiscoveryResources + summary: Browse the x402 Bazaar catalog of discoverable resources + security: [] + parameters: + - name: type + in: query + required: false + schema: + type: string + enum: [http, mcp] + - name: payTo + in: query + required: false + schema: + type: string + - name: network + in: query + required: false + description: >- + Catalog filter matching the resource's payment network. Accepts + either the Lens network name (`mainnet`/`testnet`) or the CAIP-2 id + (`stellar:pubnet`/`stellar:testnet`). + schema: + type: string + - name: extensions + in: query + required: false + description: Filter to resources declaring this extension (e.g. `bazaar`). + schema: + type: string + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: Paginated catalog page + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoveryResponse' + '429': + $ref: '#/components/responses/TooManyRequests' + + /webhooks: + post: + operationId: createWebhook + summary: Subscribe to a price-threshold webhook + description: >- + Registers an HTTPS endpoint to be called when the pair's price crosses + `threshold` in the given `direction`. The response contains the signing + secret exactly once. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [url, assetA, assetB, threshold, direction] + properties: + url: + type: string + format: uri + description: HTTPS endpoint to deliver events to. + assetA: + type: string + example: XLM + assetB: + type: string + example: USDC + threshold: + type: number + direction: + type: string + enum: [above, below] + responses: + '201': + description: Webhook created + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookCreatedResponse' + '400': + description: Invalid webhook definition + '429': + $ref: '#/components/responses/TooManyRequests' + + /webhooks/{id}: + delete: + operationId: deleteWebhook + summary: Delete a webhook subscription + security: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '204': + description: Webhook deleted (idempotent — a missing id also returns 204) + '429': + $ref: '#/components/responses/TooManyRequests' + + /usage/me: + get: + operationId: getMyUsage + summary: Get usage and quota for the calling API key + security: + - apiKeyAuth: [] + responses: + '200': + description: Usage summary for the authenticated key + content: + application/json: + schema: + $ref: '#/components/schemas/UsageResponse' + '401': + description: Missing or invalid API key + '429': + $ref: '#/components/responses/TooManyRequests' + + /supported: + get: + operationId: getSupported + summary: List the payment kinds and extensions this facilitator supports + security: [] + responses: + '200': + description: Supported payment kinds and extensions + content: + application/json: + schema: + $ref: '#/components/schemas/SupportedResponse' + + /verify: + post: + operationId: verifyPayment + summary: Verify an x402 payment payload + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyRequest' + responses: + '200': + description: Verification result + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyResponse' + '400': + description: Malformed request or invalid payment + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyResponse' + '500': + description: Internal verification error + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyResponse' + + /settle: + post: + operationId: settlePayment + summary: Settle an x402 payment (idempotent) + description: >- + Submits the payment transaction and records the attempt keyed on its + transaction hash. Retrying the same payload replays the stored answer + instead of paying twice. + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SettleRequest' + responses: + '200': + description: Settlement result + content: + application/json: + schema: + $ref: '#/components/schemas/SettleResponse' + '400': + description: Malformed payload + content: + application/json: + schema: + $ref: '#/components/schemas/SettleResponse' + + /graphql: + get: + operationId: graphqlGet + summary: Execute a GraphQL query over HTTP GET + security: [] + parameters: + - name: query + in: query + required: true + schema: + type: string + - name: variables + in: query + required: false + description: JSON-encoded variables object. + schema: + type: string + - name: operationName + in: query + required: false + schema: + type: string + responses: + '200': + description: GraphQL result + content: + application/json: + schema: + $ref: '#/components/schemas/GraphQlResponse' + post: + operationId: graphqlPost + summary: Execute a GraphQL query, mutation or subscription handshake + security: + - x402: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [query] + properties: + query: + type: string + variables: + type: object + operationName: + type: string + responses: + '200': + description: GraphQL result + content: + application/json: + schema: + $ref: '#/components/schemas/GraphQlResponse' + '402': + $ref: '#/components/responses/PaymentRequired' + +components: + securitySchemes: + x402: + type: apiKey + name: X-PAYMENT + in: header + description: Base64 encoded JSON payment proof for x402 micropayments. + adminAuth: + type: apiKey + name: X-Admin-Key + in: header + apiKeyAuth: + type: http + scheme: bearer + description: >- + Client API key issued through the admin API, sent as + `Authorization: Bearer `. + + parameters: + NetworkQuery: + name: network + in: query + required: false + 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. + schema: + type: string + enum: [testnet, mainnet] + default: testnet + + responses: + PaymentRequired: + description: Payment Required + content: + application/json: + schema: + type: object + properties: + x402Version: + type: integer + accepts: + type: array + items: + type: object + properties: + scheme: + type: string + price: + type: string + network: + type: string + payTo: + type: string + error: + type: string + description: + type: string + TooManyRequests: + description: Rate limit exceeded + headers: + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying + X-RateLimit-Remaining: + schema: + type: integer + description: Remaining requests in current window + content: + application/json: + schema: + type: object + properties: + statusCode: + type: integer + error: + type: string + message: + type: string + retryAfter: + type: string + + schemas: + Asset: + type: object + properties: + code: + type: string + issuer: + type: string + nullable: true + + AggregatedPrice: + type: object + properties: + assetA: + type: string + assetB: + type: string + pairKey: + type: string + price: + type: number + sdexPrice: + type: number + ammPrice: + type: number + bestRoute: + type: string + enum: [SDEX, AMM, SPLIT, UNKNOWN] + volume24h: + type: number + sdexVolume24h: + type: number + ammVolume24h: + type: number vwap1m: type: number vwap5m: @@ -677,3 +1295,490 @@ components: low: type: number nullable: true + + PriceLevel: + type: object + required: [price, size] + additionalProperties: false + properties: + price: + type: number + size: + type: number + + DepthResponse: + type: object + required: [assetA, assetB, pairKey, spotPrice, executionPrice, slippagePct, asks, bids, source] + additionalProperties: false + properties: + assetA: + type: string + assetB: + type: string + pairKey: + type: string + spotPrice: + type: number + executionPrice: + type: number + slippagePct: + type: number + asks: + type: array + items: + $ref: '#/components/schemas/PriceLevel' + bids: + type: array + items: + $ref: '#/components/schemas/PriceLevel' + source: + type: string + enum: [AMM, SDEX, BOTH] + + TwapResponse: + type: object + required: [assetA, assetB, pairKey, twap, windowMinutes, sampleIntervalSeconds, startTime, endTime, sampleCount, outlierRejected, filterMethod] + additionalProperties: false + properties: + assetA: + type: string + assetB: + type: string + pairKey: + type: string + twap: + type: number + windowMinutes: + type: integer + sampleIntervalSeconds: + type: integer + startTime: + type: string + format: date-time + endTime: + type: string + format: date-time + sampleCount: + type: integer + outlierRejected: + type: integer + filterMethod: + type: string + enum: [iqr, modified_zscore] + + VwapResponse: + type: object + required: [assetA, assetB, pairKey, vwap, windowMinutes, source, startTime, endTime, sampleCount, volumeTotal, outlierRejected, filterMethod] + additionalProperties: false + properties: + assetA: + type: string + assetB: + type: string + pairKey: + type: string + vwap: + type: number + windowMinutes: + type: integer + source: + type: string + startTime: + type: string + format: date-time + endTime: + type: string + format: date-time + sampleCount: + type: integer + volumeTotal: + type: number + outlierRejected: + type: integer + filterMethod: + type: string + enum: [iqr, modified_zscore] + + VolumeResponse: + type: object + required: [asset, window, startTime, endTime, totalVolume, byVenue, venues, tradeCount] + additionalProperties: false + properties: + asset: + type: string + window: + type: string + enum: [24h, 7d, 30d] + startTime: + type: string + format: date-time + endTime: + type: string + format: date-time + totalVolume: + type: number + byVenue: + type: object + additionalProperties: + type: number + venues: + type: array + items: + type: string + tradeCount: + type: integer + + SpreadVenue: + type: object + required: [venue, pairKey, bid, ask, mid, spreadBps, observations, lastSeen] + additionalProperties: false + properties: + venue: + type: string + pairKey: + type: string + bid: + type: number + ask: + type: number + mid: + type: number + spreadBps: + type: number + observations: + type: integer + lastSeen: + type: string + format: date-time + + SpreadResponse: + type: object + required: [asset, network, window, startTime, endTime, venues, tightest] + additionalProperties: false + properties: + asset: + type: string + network: + type: string + enum: [testnet, mainnet] + window: + type: string + enum: [5m, 15m, 1h, 24h] + startTime: + type: string + format: date-time + endTime: + type: string + format: date-time + venues: + type: array + items: + $ref: '#/components/schemas/SpreadVenue' + tightest: + nullable: true + allOf: + - $ref: '#/components/schemas/SpreadVenue' + + CompareResponse: + type: object + required: [asset, lens, reflector, deviationPct, status, fetchedAt] + additionalProperties: false + properties: + asset: + type: string + lens: + type: number + nullable: true + reflector: + type: number + nullable: true + deviationPct: + type: number + nullable: true + status: + type: string + enum: [aligned, minor_deviation, major_deviation, partial] + fetchedAt: + type: string + format: date-time + + ScreenerRow: + type: object + additionalProperties: true + properties: + pair_key: + type: string + volume: + type: number + price: + type: number + change_24h: + type: number + liquidity: + type: number + + ScreenerResponse: + type: object + required: [data, nextCursor, hasMore, total] + additionalProperties: false + properties: + data: + type: array + items: + $ref: '#/components/schemas/ScreenerRow' + nextCursor: + type: string + nullable: true + hasMore: + type: boolean + total: + type: integer + nullable: true + + BenchmarkResponse: + type: object + required: [asset, target, pairKey, currentPrice, currentDeviationBp, rolling24h] + additionalProperties: false + properties: + asset: + type: string + target: + type: string + pairKey: + type: string + currentPrice: + type: number + nullable: true + currentDeviationBp: + type: number + nullable: true + rolling24h: + type: object + required: [maxDeviationBp, minDeviationBp, maxAbsoluteDeviationBp, averageDeviationBp, sampleCount] + additionalProperties: false + properties: + maxDeviationBp: + type: number + nullable: true + minDeviationBp: + type: number + nullable: true + maxAbsoluteDeviationBp: + type: number + nullable: true + averageDeviationBp: + type: number + nullable: true + sampleCount: + type: integer + + BasketResponse: + type: object + required: [basketPrice, components, weightSum, computedAt] + additionalProperties: false + properties: + basketPrice: + type: number + components: + type: array + items: + type: object + required: [asset, price, weight, contribution] + additionalProperties: false + properties: + asset: + type: string + price: + type: number + weight: + type: number + contribution: + type: number + weightSum: + type: number + computedAt: + type: string + format: date-time + + DiscoveryResource: + type: object + properties: + resource: + type: object + additionalProperties: true + properties: + url: + type: string + description: + type: string + mimeType: + type: string + serviceName: + type: string + tags: + type: array + items: + type: string + iconUrl: + type: string + accepts: + type: array + items: + type: object + properties: + scheme: + type: string + price: + type: string + network: + type: string + payTo: + type: string + extensions: + type: object + additionalProperties: true + + DiscoveryResponse: + type: object + required: [resources, limit, offset, total] + additionalProperties: false + properties: + resources: + type: array + items: + $ref: '#/components/schemas/DiscoveryResource' + limit: + type: integer + offset: + type: integer + total: + type: integer + + WebhookCreatedResponse: + type: object + required: [id, secret] + additionalProperties: false + properties: + id: + type: string + secret: + type: string + + UsageResponse: + type: object + required: [keyId, dailyCalls, dailyCents, monthlyCalls, monthlyCents, dailyQuotaCents, monthlyQuotaCents, dailyRemainingCents, monthlyRemainingCents, overagePolicy, quotaExceeded] + additionalProperties: false + properties: + keyId: + type: string + dailyCalls: + type: integer + dailyCents: + type: integer + monthlyCalls: + type: integer + monthlyCents: + type: integer + dailyQuotaCents: + type: integer + monthlyQuotaCents: + type: integer + dailyRemainingCents: + type: integer + monthlyRemainingCents: + type: integer + overagePolicy: + type: string + enum: [block, charge_402, allow_overage] + quotaExceeded: + type: boolean + + VerifyRequest: + type: object + required: [paymentPayload, paymentRequirements] + properties: + x402Version: + type: integer + paymentPayload: + type: object + paymentRequirements: + type: object + + VerifyResponse: + type: object + required: [isValid] + additionalProperties: true + properties: + isValid: + type: boolean + invalidReason: + type: string + nullable: true + invalidMessage: + type: string + nullable: true + payer: + type: string + nullable: true + + SettleRequest: + type: object + required: [paymentPayload, paymentRequirements] + properties: + x402Version: + type: integer + paymentPayload: + type: object + paymentRequirements: + type: object + + SettleResponse: + type: object + required: [success, transaction, network] + additionalProperties: true + properties: + success: + type: boolean + errorReason: + type: string + nullable: true + errorMessage: + type: string + nullable: true + transaction: + type: string + network: + type: string + + SupportedResponse: + type: object + additionalProperties: true + properties: + kinds: + type: array + items: + type: object + properties: + scheme: + type: string + network: + type: string + extensions: + type: array + items: + type: string + + GraphQlResponse: + type: object + additionalProperties: true + properties: + data: + type: object + nullable: true + errors: + type: array + items: + type: object + properties: + message: + type: string diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 49d42948..96b450f3 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -1,9 +1,36 @@ import { readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' import YAML from 'js-yaml' -const sourcePath = new URL('../openapi.yaml', import.meta.url) -const destinationPath = new URL('../openapi.json', import.meta.url) -const contents = readFileSync(sourcePath, 'utf8') -const document = YAML.load(contents) -writeFileSync(destinationPath, JSON.stringify(document, null, 2) + '\n') -console.log('Generated openapi.json') +const sourcePath = fileURLToPath(new URL('../openapi.yaml', import.meta.url)) +const destinationPath = fileURLToPath(new URL('../openapi.json', import.meta.url)) + +/** + * Renders the committed `openapi.json` from `openapi.yaml`. + * + * Exported (rather than inlined in the CLI) so `tests/openapi.test.ts` can + * assert that the committed JSON is exactly what the generator produces — the + * spec is auto-published from openapi.json on every push, so a stale committed + * file is a broken public contract even though the source YAML looks right. + * + * Deterministic by construction: `js-yaml` preserves document key order and + * `JSON.stringify` is stable for a fixed object, so the same YAML always yields + * byte-identical output. The test runs it twice to guarantee that. + */ +export function renderOpenApiJson(yamlSource?: string): string { + const source = yamlSource ?? readFileSync(sourcePath, 'utf8') + const document = YAML.load(source) + return JSON.stringify(document, null, 2) + '\n' +} + +/** Regenerates `openapi.json` from `openapi.yaml`, in place. */ +export function generateOpenApi(): void { + writeFileSync(destinationPath, renderOpenApiJson()) +} + +// Run only when invoked directly (`npm run openapi:gen`), not when imported by +// the test. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + generateOpenApi() + console.log('Generated openapi.json') +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 00000000..da11e200 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,150 @@ +import Fastify, { type FastifyInstance, type RouteOptions } from 'fastify' +import cors from '@fastify/cors' +import compress from '@fastify/compress' +import rateLimit from '@fastify/rate-limit' +import { registerRESTRoutes } from './api/rest' +import { registerGraphQL } from './api/graphql' +import { registerWebhookRoutes } from './routes/webhooks' +import { registerCandleRoutes } from './routes/candles' +import { registerPairsRoutes } from './routes/pairs' +import { registerScreenerRoutes } from './routes/screener' +import { registerHistoryRoutes } from './api/history' +import { registerX402 } from './middleware/x402' +import { registerNetworkSelector } from './middleware/network' +import { registerHttpMetrics } from './middleware/httpMetrics' +import { registerWebSocket } from './api/websocket' +import { registerApiKeyAuth } from './api/auth' +import { registerAdminRoutes } from './api/admin' +import { registerUsageRoutes } from './api/usage' +import { registerFacilitatorRoutes } from './api/facilitator' +import { registerPriceRoutes } from './routes/price' +import { registerVolumeRoutes } from './routes/volumes' +import { registerSpreadsRoutes } from './routes/spreads' +import { registerBenchmarkRoutes } from './routes/benchmark' +import { registerOracleRoutes } from './routes/oracle' +import { registerBasketRoutes } from './routes/basket' +import { registerDiscoveryRoutes } from './routes/discovery' +import { registerSettleRoute } from './routes/facilitator' +import { getMetrics } from './metrics' + +export interface BuildAppOptions { + /** + * Invoked for every route Fastify registers, before the app is ready. + * + * Exists so the OpenAPI coverage test can enumerate the exact route surface + * this process exposes (see tests/openapi.test.ts) without re-listing the + * routes — a duplicated list would drift and defeat the point. + */ + onRoute?: (routeOptions: RouteOptions) => void +} + +/** + * Builds the fully-wired Fastify API — everything `src/index.ts` used to do + * inline between creating the server and calling `listen()`. + * + * Split out from `main()` so tests can boot the real route surface (all + * plugins, hooks and routes, in the same order) without connecting to + * Postgres/Redis or starting the ingesters. `index.ts` owns those side + * effects. + * + * The returned app is NOT listening; the caller calls `app.listen(...)`. + */ +export async function buildApp(options: BuildAppOptions = {}): Promise { + const app = Fastify({ logger: { level: 'warn' } }) + + // Capture route registrations first, so it observes every route below + // (including the ones registered by encapsulated plugins such as Mercurius). + if (options.onRoute) { + app.addHook('onRoute', options.onRoute) + } + + await app.register(cors, { origin: true }) + await app.register(compress) + + // HTTP request/latency metrics. Registered FIRST, ahead of the network + // selector, API-key auth, the rate limiter and x402, so that requests those + // plugins reject (400/401/402/429) still have their timer started and are + // counted. See src/middleware/httpMetrics.ts. + await app.register(registerHttpMetrics) + + // Resolves the per-request Stellar network (?network= query param / x-network + // header) onto req.network, validating it (400 on an unrecognised value). + // Runs in onRequest, ahead of API-key auth/rate-limiting/x402 and every route + // handler, so all of them can read req.network. + await app.register(registerNetworkSelector) + + // API-key authentication — validates Authorization: Bearer and attaches + // per-key quota metadata to req.apiKey. Registered BEFORE the rate limiter so + // that req.apiKey is populated when the limiter evaluates its per-key quota + // (both run in the onRequest phase, in registration order). Disabled when + // REQUIRE_API_KEY=false. Routes marked `config.public` bypass auth. + if (process.env.REQUIRE_API_KEY !== 'false') { + await app.register(registerApiKeyAuth) + } else { + app.log.warn('[auth] REQUIRE_API_KEY=false — API key authentication disabled') + } + + // Per-key rate quotas: the limit is derived from the authenticated key's + // metadata (req.apiKey, set by the auth hook above). Unauthenticated/public + // requests fall back to a conservative shared limit keyed by IP. The IP + // fallback is overridable via RATE_LIMIT_IP_MAX so load tests (which flood + // from a single IP without a key) can measure the endpoint, not the limiter. + const ipRateLimitMax = parseInt(process.env.RATE_LIMIT_IP_MAX ?? '100', 10) + await app.register(rateLimit, { + max: (req) => req.apiKey?.ratePerMin ?? ipRateLimitMax, + timeWindow: '1 minute', + keyGenerator: (req) => req.apiKey?.id ?? req.ip, + allowList: (req) => req.url === '/status', + errorResponseBuilder: (req, context) => ({ + statusCode: 429, + error: 'Too Many Requests', + message: `Rate limit exceeded, retry in ${context.after}`, + retryAfter: context.after + }) + }) + + // Specific limit for /status (higher for monitoring) + app.addHook('onRoute', (routeOptions) => { + if (routeOptions.url === '/status') { + routeOptions.config = { + ...routeOptions.config, + rateLimit: { + max: 1000, + timeWindow: '1 minute' + } + } + } + }) + + // Admin endpoints (key issuance/revocation) — gated by ADMIN_TOKEN. Marked + // `config.public` so the API-key auth hook skips them. + await registerAdminRoutes(app) + await registerUsageRoutes(app) + await app.register(registerFacilitatorRoutes) + + await app.register(registerX402) + await registerRESTRoutes(app) + await registerWebhookRoutes(app) + await registerCandleRoutes(app) + await registerPairsRoutes(app) + await registerScreenerRoutes(app) + await registerHistoryRoutes(app) + await registerPriceRoutes(app) + await registerVolumeRoutes(app) + await registerSpreadsRoutes(app) + await registerBenchmarkRoutes(app) + await registerOracleRoutes(app) + await registerBasketRoutes(app) + await registerDiscoveryRoutes(app) + await registerSettleRoute(app) + await registerGraphQL(app) + await registerWebSocket(app) + + // Prometheus metrics endpoint (un-gated, public — no API key required) + app.get('/metrics', { config: { public: true } }, async (req, reply) => { + reply.type('text/plain; version=0.0.4; charset=utf-8') + return await getMetrics() + }) + + return app +} diff --git a/src/index.ts b/src/index.ts index dcd72a7e..e1bc6762 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,40 +1,14 @@ import 'dotenv/config' import { execSync } from 'child_process' -import Fastify from 'fastify' if (!process.env.DIRECT_DATABASE_URL && process.env.DATABASE_URL) { process.env.DIRECT_DATABASE_URL = process.env.DATABASE_URL } -import cors from '@fastify/cors' -import compress from '@fastify/compress' -import rateLimit from '@fastify/rate-limit' import { config, type NetworkName } from './config' import { getEnabledNetworks } from './network/enabledNetworks' import { redis } from './redis' import { pgPool } from './db' -import { registerRESTRoutes } from './api/rest' -import { registerGraphQL } from './api/graphql' -import { registerWebhookRoutes } from './routes/webhooks' -import { registerCandleRoutes } from './routes/candles' -import { registerPairsRoutes } from './routes/pairs' -import { registerScreenerRoutes } from './routes/screener' -import { registerHistoryRoutes } from './api/history' -import { registerX402 } from './middleware/x402' -import { registerNetworkSelector } from './middleware/network' -import { registerHttpMetrics } from './middleware/httpMetrics' -import { registerWebSocket } from './api/websocket' -import { registerApiKeyAuth } from './api/auth' -import { registerAdminRoutes } from './api/admin' -import { registerUsageRoutes } from './api/usage' -import { registerFacilitatorRoutes } from './api/facilitator' -import { registerPriceRoutes } from './routes/price' -import { registerVolumeRoutes } from './routes/volumes' -import { registerSpreadsRoutes } from './routes/spreads' -import { registerBenchmarkRoutes } from './routes/benchmark' -import { registerOracleRoutes } from './routes/oracle' -import { registerBasketRoutes } from './routes/basket' -import { registerDiscoveryRoutes } from './routes/discovery' -import { registerSettleRoute } from './routes/facilitator' +import { buildApp } from './app' import { fanOutManager } from './ws/fanout' import { startSDEXIngester } from './ingesters/sdex' @@ -45,7 +19,6 @@ import { startAquariusIngester } from './ingest/venues/aquarius' import { createAggregateQueue, startAggregateWorker, scheduleAggregateRefresh } from './jobs/aggregateRefresh' import { createSnapshotRetentionQueue, startSnapshotRetentionWorker, scheduleSnapshotRetention, pruneAllNetworks, SNAPSHOT_RETENTION_DAYS } from './jobs/snapshotRetention' import { loadPersistedPairs, getActivePairs } from './pairsRegistry' -import { getMetrics } from './metrics' async function main() { // ── Ensure DB schema is up-to-date ──────────────────────────────────────── @@ -79,94 +52,10 @@ async function main() { await loadPersistedPairs() // ── Fastify API server ──────────────────────────────────────────────────── - const app = Fastify({ logger: { level: 'warn' } }) - await app.register(cors, { origin: true }) - await app.register(compress) - - // HTTP request/latency metrics. Registered FIRST, ahead of the network - // selector, API-key auth, the rate limiter and x402, so that requests those - // plugins reject (400/401/402/429) still have their timer started and are - // counted. See src/middleware/httpMetrics.ts. - await app.register(registerHttpMetrics) - - // Resolves the per-request Stellar network (?network= query param / x-network - // header) onto req.network, validating it (400 on an unrecognised value). - // Runs in onRequest, ahead of API-key auth/rate-limiting/x402 and every route - // handler, so all of them can read req.network. - await app.register(registerNetworkSelector) - - // API-key authentication — validates Authorization: Bearer and attaches - // per-key quota metadata to req.apiKey. Registered BEFORE the rate limiter so - // that req.apiKey is populated when the limiter evaluates its per-key quota - // (both run in the onRequest phase, in registration order). Disabled when - // REQUIRE_API_KEY=false. Routes marked `config.public` bypass auth. - if (process.env.REQUIRE_API_KEY !== 'false') { - await app.register(registerApiKeyAuth) - } else { - app.log.warn('[auth] REQUIRE_API_KEY=false — API key authentication disabled') - } - - // Per-key rate quotas: the limit is derived from the authenticated key's - // metadata (req.apiKey, set by the auth hook above). Unauthenticated/public - // requests fall back to a conservative shared limit keyed by IP. The IP - // fallback is overridable via RATE_LIMIT_IP_MAX so load tests (which flood - // from a single IP without a key) can measure the endpoint, not the limiter. - const ipRateLimitMax = parseInt(process.env.RATE_LIMIT_IP_MAX ?? '100', 10) - await app.register(rateLimit, { - max: (req) => req.apiKey?.ratePerMin ?? ipRateLimitMax, - timeWindow: '1 minute', - keyGenerator: (req) => req.apiKey?.id ?? req.ip, - allowList: (req) => req.url === '/status', - errorResponseBuilder: (req, context) => ({ - statusCode: 429, - error: 'Too Many Requests', - message: `Rate limit exceeded, retry in ${context.after}`, - retryAfter: context.after - }) - }) - - // Specific limit for /status (higher for monitoring) - app.addHook('onRoute', (routeOptions) => { - if (routeOptions.url === '/status') { - routeOptions.config = { - ...routeOptions.config, - rateLimit: { - max: 1000, - timeWindow: '1 minute' - } - } - } - }) - - // Admin endpoints (key issuance/revocation) — gated by ADMIN_TOKEN. Marked - // `config.public` so the API-key auth hook skips them. - await registerAdminRoutes(app) - await registerUsageRoutes(app) - await app.register(registerFacilitatorRoutes) - - await app.register(registerX402) - await registerRESTRoutes(app) - await registerWebhookRoutes(app) - await registerCandleRoutes(app) - await registerPairsRoutes(app) - await registerScreenerRoutes(app) - await registerHistoryRoutes(app) - await registerPriceRoutes(app) - await registerVolumeRoutes(app) - await registerSpreadsRoutes(app) - await registerBenchmarkRoutes(app) - await registerOracleRoutes(app) - await registerBasketRoutes(app) - await registerDiscoveryRoutes(app) - await registerSettleRoute(app) - await registerGraphQL(app) - await registerWebSocket(app) - - // Prometheus metrics endpoint (un-gated, public — no API key required) - app.get('/metrics', { config: { public: true } }, async (req, reply) => { - reply.type('text/plain; version=0.0.4; charset=utf-8') - return await getMetrics() - }) + // All plugin and route wiring lives in buildApp() (src/app.ts) so tests can + // boot the exact same route surface without the connections and background + // workers started below. + const app = await buildApp() await app.listen({ port: config.api.port, host: config.api.host }) console.log(`[lens] API listening on http://${config.api.host}:${config.api.port}`) diff --git a/src/openapi/coverage.ts b/src/openapi/coverage.ts new file mode 100644 index 00000000..402a44e5 --- /dev/null +++ b/src/openapi/coverage.ts @@ -0,0 +1,80 @@ +/** + * Route ↔ OpenAPI coverage bookkeeping. + * + * `openapi.yaml` is published to GitHub Pages on every push, so it is a public + * contract. Nothing used to stop it drifting behind the routes Fastify actually + * registers — it documented seven of roughly thirty. `tests/openapi.test.ts` + * now boots the app, enumerates every registered route and fails when a route + * has no matching `openapi.yaml` entry, so the gap cannot silently widen again. + * + * That test needs to know which routes are deliberately *not* part of the + * public contract. Rather than silently skipping them (which is how a public + * route gets forgotten), every exception is listed in `INTERNAL_ROUTES` below + * with a reason. Anything not listed there must appear in the spec. + */ + +/** + * Routes that are intentionally excluded from the public OpenAPI contract. + * + * Entries are OpenAPI-style paths (path parameters in `{braces}`), not the + * Fastify `:param` form — see {@link toOpenApiPath}. Adding a route here is a + * deliberate statement that it is not part of the published API; if you only + * want to skip documenting something, don't — document it instead. + */ +export const INTERNAL_ROUTES: readonly string[] = [ + // Prometheus scrape endpoint. Infrastructure, not a client-facing API: it + // speaks the Prometheus text exposition format, not JSON. + '/metrics', + + // GraphiQL is the browser IDE for /graphql. It returns an HTML document + // assembled by Mercurius, not data. + '/graphiql', + + // WebSocket price stream. OpenAPI 3.0 has no way to describe a websocket + // protocol; the equivalent GraphQL subscription is documented instead. + '/ws', + + // Operator-only API-key issuance and revocation. Authenticated with a shared + // ADMIN_TOKEN rather than a client API key (see src/api/admin.ts). + '/admin/keys', + '/admin/keys/{id}', + + // Operator-only usage/quota reporting for minted keys. + '/admin/usage', + '/admin/usage/{keyId}', +] as const + +/** + * Route prefixes that are internal, for paths whose exact set is owned by a + * dependency rather than by us. Kept separate from `INTERNAL_ROUTES` (which is + * exact-match) so an accidental broad prefix cannot quietly excuse a real + * public route: the only entry today is GraphiQL's static assets, registered + * by Mercurius alongside `/graphiql`. + */ +export const INTERNAL_ROUTE_PREFIXES: readonly string[] = [ + // Mercurius serves /graphiql/main.js, /graphiql/sw.js, /graphiql/config.js + // and friends from this namespace. They are part of the GraphiQL IDE, which + // is already allow-listed above. + '/graphiql/', +] as const + +const INTERNAL_ROUTE_SET = new Set(INTERNAL_ROUTES) + +/** + * Rewrites a Fastify route template into its OpenAPI path equivalent: + * `/price/:assetA/:assetB` → `/price/{assetA}/{assetB}`. + * + * Both frameworks use the same path syntax otherwise, so a single substitution + * of `:name` for `{name}` is all that is needed. + */ +export function toOpenApiPath(fastifyUrl: string): string { + return fastifyUrl.replace(/:([A-Za-z0-9_]+)/g, '{$1}') +} + +/** Whether a route (in OpenAPI path form) is on the internal allow-list. */ +export function isInternalRoute(openApiPath: string): boolean { + return ( + INTERNAL_ROUTE_SET.has(openApiPath) || + INTERNAL_ROUTE_PREFIXES.some(prefix => openApiPath.startsWith(prefix)) + ) +} diff --git a/tests/openapi.test.ts b/tests/openapi.test.ts new file mode 100644 index 00000000..39f8918f --- /dev/null +++ b/tests/openapi.test.ts @@ -0,0 +1,102 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import type { FastifyInstance, RouteOptions } from 'fastify' +import YAML from 'js-yaml' +import { buildApp } from '../src/app' +import { INTERNAL_ROUTES, isInternalRoute, toOpenApiPath } from '../src/openapi/coverage' +import { renderOpenApiJson } from '../scripts/generate-openapi' + +const specPath = fileURLToPath(new URL('../openapi.yaml', import.meta.url)) +const jsonPath = fileURLToPath(new URL('../openapi.json', import.meta.url)) + +interface RegisteredRoute { + method: string + path: string +} + +interface Spec { + paths: Record> +} + +let app: FastifyInstance +let registered: RegisteredRoute[] +let spec: Spec + +beforeAll(async () => { + registered = [] + // Boot the real app so the route list is exactly what the process exposes — + // re-listing the routes here would drift and defeat the purpose. + app = await buildApp({ + onRoute: (route: RouteOptions) => { + const methods = Array.isArray(route.method) ? route.method : [route.method] + for (const method of methods) { + // HEAD is auto-generated from GET and OPTIONS is framework-level; + // neither needs its own OpenAPI operation. + if (method === 'HEAD' || method === 'OPTIONS') continue + registered.push({ method, path: toOpenApiPath(route.url) }) + } + }, + }) + await app.ready() + spec = YAML.load(readFileSync(specPath, 'utf8')) as Spec +}) + +afterAll(async () => { + await app?.close() +}) + +describe('OpenAPI route coverage', () => { + it('boots an app with routes to check', () => { + expect(registered.length).toBeGreaterThan(20) + }) + + it('documents every non-internal route in openapi.yaml', () => { + const missing = registered.filter(r => !isInternalRoute(r.path) && !(r.path in spec.paths)) + expect(missing, `Undocumented routes: ${JSON.stringify(missing)}`).toEqual([]) + }) + + it('documents the HTTP method for every documented route', () => { + const missing = registered.filter( + r => !isInternalRoute(r.path) && !(r.method.toLowerCase() in (spec.paths[r.path] ?? {})), + ) + expect(missing, `Undocumented operations: ${JSON.stringify(missing)}`).toEqual([]) + }) + + it('documents only paths that are actually registered', () => { + const registeredPaths = new Set(registered.map(r => r.path)) + const phantom = Object.keys(spec.paths).filter(p => !registeredPaths.has(p)) + expect(phantom, `Documented but unregistered: ${JSON.stringify(phantom)}`).toEqual([]) + }) + + it('documents only operations that are actually registered', () => { + const registeredOperations = new Set( + registered.map(r => `${r.method.toLowerCase()} ${r.path}`), + ) + const phantom: string[] = [] + for (const [path, operations] of Object.entries(spec.paths)) { + for (const method of Object.keys(operations)) { + if (!registeredOperations.has(`${method.toLowerCase()} ${path}`)) { + phantom.push(`${method.toUpperCase()} ${path}`) + } + } + } + expect(phantom, `Documented but unregistered: ${JSON.stringify(phantom)}`).toEqual([]) + }) + + it('allow-lists only routes that are actually registered', () => { + const registeredPaths = new Set(registered.map(r => r.path)) + const stale = INTERNAL_ROUTES.filter(p => !registeredPaths.has(p)) + expect(stale, `Stale internal routes: ${JSON.stringify(stale)}`).toEqual([]) + }) +}) + +describe('generated openapi.json', () => { + it('matches the committed file', () => { + expect(renderOpenApiJson()).toBe(readFileSync(jsonPath, 'utf8')) + }) + + it('regenerates deterministically', () => { + expect(renderOpenApiJson()).toBe(renderOpenApiJson()) + }) +})